Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
88f5c4f
test(policy): cover host skill scan verdicts and denial precedence
senamakel Oct 23, 2026
dae1135
feat(bus): add skill scan verdict check to policy contract
senamakel Oct 23, 2026
f205ed7
fix(approval): treat skill scan checks as non-external
senamakel Oct 23, 2026
f6256bb
test(policy): import SkillScanVerdict in engine tests
senamakel Oct 23, 2026
9a950f5
test(tinysecurity-policy): rename loop variable in skill scan verdict…
senamakel Oct 23, 2026
fecf3bc
feat(policy): bind skill scan approvals to bundle digest
senamakel Oct 23, 2026
893ef9c
docs: document host skill-scan contract 1.29
senamakel Oct 23, 2026
5c4abe5
test(approval): cover skill scan warn and block verdicts
senamakel Oct 23, 2026
b8be187
test(approval): assert denied lifecycle state instead of error
senamakel Oct 23, 2026
d2d1187
test(approval): narrow skill scan test to human approval path
senamakel Oct 23, 2026
5599b28
docs: expand skill-scan contract 1.29 spec
senamakel Oct 23, 2026
a5fdecc
test(approval): cover skill scan block under auto-approve scope
senamakel Oct 23, 2026
09fdf07
fix(approval): require human approval for blocked and warned skill scans
senamakel Oct 23, 2026
2237f3b
test(adapter-host): cover host skill scan verdicts before run_workflow
senamakel Oct 23, 2026
758a779
refactor(approval): extract blocked skill scan check and flatten cond…
senamakel Oct 23, 2026
e94c723
test(policy): cover skill scan pass without effect check
senamakel Oct 23, 2026
4adef47
fix(policy): stop treating skill scan checks as host tool calls
senamakel Oct 23, 2026
2f85664
docs: clarify skill scan is supplemental and additive for 1.28 hosts
senamakel Oct 23, 2026
8dfbb39
test(policy): simplify contract version test setup
senamakel Oct 23, 2026
7721860
test(adapter-host-tool): cover skill scan checks in admission-only de…
senamakel Oct 23, 2026
d9d2590
fix(adapter): treat skill scans as admission-only checks
senamakel Oct 23, 2026
24ef88e
style: reformat admission_only check for readability
senamakel Oct 23, 2026
0e8e631
docs(tinysecurity-module): clarify admission_only doc comment
senamakel Oct 23, 2026
e7f1b32
test: isolate process-owned confidential fixtures in child processes
senamakel Oct 23, 2026
65aa790
docs(skill-scan): mark contract 1.29 as accepted
senamakel Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ Full details: [paired-device lifecycle specification](docs/specs/device-pairing-
## Browser destination authorization (contract 1.28)
Full details: [browser dial specification](docs/specs/browser-dial-contract-1.28.md).

## Host skill-scan decisions (contract 1.29)
Full details: [host skill-scan specification](docs/specs/skill-scan-contract-1.29.md).

## Confidential injected master resolution
Full details: [`Confidential injected master resolution`](docs/module-crypto.md#confidential-injected-master-resolution).

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ privacy modes deny this uninspectable traffic; TinyComputer remains responsible
for enforcing the proxy and dialing the approved socket. See the
[browser-dial contract specification](docs/specs/browser-dial-contract-1.28.md).

Contract 1.29 adds `Check::SkillScan` so the trusted host can submit the scan
verdict and exact bundle digest before an installed skill runs. `Block` is a
content denial, `Warn` enters the host's approval flow, and `Pass` leaves the
remaining policy checks authoritative. See the
[host skill-scan specification](docs/specs/skill-scan-contract-1.29.md).

Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with
authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md).
The audited permission methods derive events from the actual original request,
Expand Down
16 changes: 15 additions & 1 deletion crates/tinysecurity-approval/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ pub(crate) fn native_safe_call(request: &EvaluateRequest) -> Result<String> {
}
Ok(safe_call)
}

fn contains_blocked_skill_scan(checks: &[Check]) -> bool {
checks.iter().any(|check| {
matches!(
check,
Check::SkillScan {
verdict: tinysecurity_bus::SkillScanVerdict::Block,
..
}
)
})
}

pub(crate) fn original(
reg: &ApprovalScope2,
request: &EvaluateRequest,
Expand All @@ -113,6 +126,7 @@ pub(crate) fn original(
|| request.call.arguments_json.len() > 32768
|| request.call.checks.is_empty()
|| request.call.checks.len() > 128
|| contains_blocked_skill_scan(&request.call.checks)
|| (reg.ceiling == AccessTier::Read && request.caller.tier != AccessTier::Read)
|| (reg.ceiling == AccessTier::Write && request.caller.tier == AccessTier::Full)
|| request
Expand All @@ -137,7 +151,7 @@ pub(crate) fn original(
args.sort_all_objects();
let canonical = serde_json::to_string(&args).map_err(|_| Error::Invalid)?;
let external = request.call.checks.iter().any(|check| match check {
Check::LocalDiagnostic {} => false,
Check::LocalDiagnostic {} | Check::SkillScan { .. } => false,
Check::Path { write, .. } => *write,
Check::HostAction {
external_effect, ..
Expand Down
10 changes: 10 additions & 0 deletions crates/tinysecurity-approval/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,17 @@ impl Engine {
now: u64,
native: bool,
) -> Result<(Option<ApprovalRow2>, bool)> {
let scan_warning = request.request.call.checks.iter().any(|check| {
matches!(
check,
Check::SkillScan {
verdict: tinysecurity_bus::SkillScanVerdict::Warn,
..
}
)
});
let human_only = native
|| scan_warning
|| matches!(
request.request.call.tool.as_str(),
"sandbox_host_network" | "device_crypto"
Expand Down
27 changes: 27 additions & 0 deletions crates/tinysecurity-approval/src/engine_scopes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,33 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu
Ok(())
}

#[tokio::test]
async fn skill_scan_warning_enters_human_approval() -> Result<()> {
let (_, owner) = owner()?;
let mut auto_approve = register(7);
auto_approve.scope.auto_approve_all = true;
auto_approve.grants = grants(&["run_workflow"], &[]);
owner.register_scope(auto_approve).await?;

let mut warning = begin_for(7, "run_workflow", None, Origin::Operator);
warning.request.call.checks = vec![Check::SkillScan {
verdict: tinysecurity_bus::SkillScanVerdict::Warn,
bundle_sha256: "a".repeat(64),
}];
let pending = owner.begin(warning).await?;
assert_eq!(pending.state, ApprovalLifecycle2::Pending);
assert!(!pending.external_effect);

let mut blocked = begin_for(7, "run_workflow", None, Origin::Operator);
blocked.request.caller.call_id = "blocked-call".into();
blocked.request.call.checks = vec![Check::SkillScan {
verdict: tinysecurity_bus::SkillScanVerdict::Block,
bundle_sha256: "b".repeat(64),
}];
assert_eq!(owner.begin(blocked).await.err(), Some(Error::Denied));
Ok(())
}

#[tokio::test]
async fn host_action_and_shell_checks_bind_with_their_own_external_effect() -> Result<()> {
let (_, owner) = owner()?;
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/device_frame_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,5 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape()
#[test]
fn frame_member_name_is_stable_and_contract_version_is_bumped() {
assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2");
assert_eq!(crate::CONTRACT_VERSION, (1, 28));
assert_eq!(crate::CONTRACT_VERSION, (1, 29));
}
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/device_pairing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() {

#[test]
fn pairing_contract_is_versioned_after_the_frame_contract() {
assert_eq!(CONTRACT_VERSION, (1, 28));
assert_eq!(CONTRACT_VERSION, (1, 29));
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ pub use scoped_policy::*;
pub use tool_trust::*;
pub mod names;
/// Current wire vocabulary version, independent of the artifact version.
pub const CONTRACT_VERSION: (u32, u32) = (1, 28);
pub const CONTRACT_VERSION: (u32, u32) = (1, 29);
Comment thread
senamakel marked this conversation as resolved.
/// Whether a remote module's advertised version supplies this host's vocabulary.
///
/// `version` is the provider version received by the host, not a host requirement
Expand Down
25 changes: 25 additions & 0 deletions crates/tinysecurity-bus/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ pub struct ToolCall {
/// Never forward checks supplied by a model, tool client or remote caller.
pub checks: Vec<Check>,
}

/// Host-computed verdict from scanning a skill before execution (contract 1.29).
/// The host must scan the exact installed document and resources it will expose;
/// this value is a trusted host assertion, never a model-supplied claim. It is
/// supplemental to the host-effect check and cannot authorize a tool call alone.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SkillScanVerdict {
/// No findings require action.
Pass,
/// Findings require the existing host approval flow before execution.
Warn,
/// The scan found content that must not execute.
Block,
}

/// A check of a concrete operation. The bootstrap has no URL authorization.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
Expand Down Expand Up @@ -141,6 +157,15 @@ pub enum Check {
#[serde(default, skip_serializing_if = "Option::is_none")]
command_policy: Option<CommandPolicyId>,
},
/// A trusted host's scan result for the exact skill bundle being executed
/// (contract 1.29). `Warn` requires host approval; `Block` is a hard denial.
SkillScan {
Comment thread
senamakel marked this conversation as resolved.
/// Result computed by the host scanner.
verdict: SkillScanVerdict,
/// Lowercase SHA-256 of the exact document and resources the host will
/// expose during this run. The approval binding includes this value.
bundle_sha256: String,
},
/// An effect not supported by this engine.
Unknown {
/// Content-free descriptor.
Expand Down
25 changes: 25 additions & 0 deletions crates/tinysecurity-bus/src/policy_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,31 @@ fn host_shell_has_a_stable_strict_wire_form() -> TestResult {
Ok(())
}

#[test]
fn skill_scan_has_stable_verdict_wire_forms() -> TestResult {
for (verdict, wire) in [
(SkillScanVerdict::Pass, "pass"),
(SkillScanVerdict::Warn, "warn"),
(SkillScanVerdict::Block, "block"),
] {
let check = Check::SkillScan {
verdict,
bundle_sha256: "a".repeat(64),
};
let value = serde_json::to_value(&check)?;
assert_eq!(
value,
serde_json::json!({
"kind":"skill_scan",
"verdict":wire,
"bundle_sha256":"a".repeat(64),
})
);
assert_eq!(serde_json::from_value::<Check>(value)?, check);
}
Ok(())
}

#[test]
fn new_denial_reasons_have_stable_wire_forms_and_old_ones_are_unchanged() -> TestResult {
for (reason, wire) in [
Expand Down
10 changes: 5 additions & 5 deletions crates/tinysecurity-bus/tests/scoped_wire.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Registered subject policy wire shapes and the 1.28 compatibility boundary.
//! Registered subject policy wire shapes and the 1.29 compatibility boundary.
use serde_json::json;
use tinysecurity_bus::*;

Expand Down Expand Up @@ -87,10 +87,10 @@ fn new_members_are_advertised_and_named_by_contract_constants() {

#[test]
fn contract_bump_is_additive_for_older_hosts_and_modules() {
assert_eq!(CONTRACT_VERSION, (1, 28));
// A host built against 1.28 accepts this module and newer minors, not a 1.27 module.
assert!(is_compatible((1, 28)));
assert!(!is_compatible((1, 27)));
assert_eq!(CONTRACT_VERSION, (1, 29));
// A host built against 1.29 accepts this module and newer minors, not a 1.28 module.
assert!(is_compatible((1, 29)));
assert!(!is_compatible((1, 28)));
assert!(!is_compatible((1, 26)));
assert!(!is_compatible((1, 25)));
assert!(!is_compatible((1, 24)));
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/tests/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info()
serde_json::to_value(&config)?["redactor"]["secrets"][0],
"literal-private"
);
assert_eq!(CONTRACT_VERSION, (1, 28));
assert_eq!(CONTRACT_VERSION, (1, 29));
for (mode, wire) in [
(RedactMode::Text, "text"),
(RedactMode::StructuredArguments, "structured_arguments"),
Expand Down
15 changes: 10 additions & 5 deletions crates/tinysecurity-module/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1572,13 +1572,18 @@ fn permission_payload(mut original: serde_json::Value) -> BusResult<String> {
fn approval_failure(error: tinysecurity::approval::Error) -> tinybus::Error {
tinybus::Error::failed(error.to_string())
}
/// Whether every check is a host-tool or shell admission question (contract 1.20).
/// Whether host-tool checks are admission-only, with an optional supplemental scan.
fn admission_only(checks: &[tinysecurity_bus::Check]) -> bool {
use tinysecurity_bus::Check;
!checks.is_empty()
&& checks
.iter()
.all(|check| matches!(check, Check::HostAction { .. } | Check::HostShell { .. }))
checks
.iter()
.any(|check| matches!(check, Check::HostAction { .. } | Check::HostShell { .. }))
&& checks.iter().all(|check| {
matches!(
check,
Check::HostAction { .. } | Check::HostShell { .. } | Check::SkillScan { .. }
)
})
}
fn deny_allow(decision: &mut Decision) {
if matches!(decision.verdict, tinysecurity_bus::Verdict::Allow) {
Expand Down
54 changes: 54 additions & 0 deletions crates/tinysecurity-module/src/adapter_host_tool_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,52 @@ async fn evaluate_reads_and_spends_the_named_command_scopes_budget() -> TestResu
Ok(())
}

#[tokio::test]
async fn evaluate_applies_host_skill_scan_verdicts_before_run_workflow() -> TestResult {
let service = service()?;
for (verdict, expected) in [
(
tinysecurity_bus::SkillScanVerdict::Block,
Verdict::Deny {
reason: DenialReason::Content,
},
),
(
tinysecurity_bus::SkillScanVerdict::Warn,
Verdict::Deny {
reason: DenialReason::ApprovalUnavailable,
},
),
(tinysecurity_bus::SkillScanVerdict::Pass, Verdict::Allow),
] {
assert_eq!(
evaluate(
&service,
request(
"run_workflow",
vec![
Check::HostAction {
permission: ToolPermission::None,
external_effect: false,
spend: false,
command_policy: None,
},
Check::SkillScan {
verdict,
bundle_sha256: "c".repeat(64),
},
],
),
)
.await?
.verdict,
expected,
"{verdict:?}"
);
}
Ok(())
}

#[tokio::test]
async fn evaluate_gates_a_shell_command_under_the_named_scope() -> TestResult {
let service = service()?;
Expand Down Expand Up @@ -242,6 +288,14 @@ async fn a_mixed_request_is_not_an_admission_and_stays_audit_bound() -> TestResu
assert!(!admission_only(&call.call.checks));
call.call.checks = vec![acting(false, &scope)];
assert!(admission_only(&call.call.checks));
let scan = Check::SkillScan {
verdict: tinysecurity_bus::SkillScanVerdict::Pass,
bundle_sha256: "a".repeat(64),
};
call.call.checks = vec![acting(false, &scope), scan.clone()];
assert!(admission_only(&call.call.checks));
call.call.checks = vec![scan];
assert!(!admission_only(&call.call.checks));
Ok(())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,12 @@ impl Control {
#[tokio::test]
async fn actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed()
-> TestResult {
if isolate_process_owned_fixture(
"actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed",
"cipher",
)? {
return Ok(());
}
for mode in 20..=32 {
let bus = MemoryBus::new();
let owner = Broker::new();
Expand Down
29 changes: 29 additions & 0 deletions crates/tinysecurity-module/src/adapter_secret_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ use std::sync::atomic::{AtomicU32, Ordering};
use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus};
use tinysecurity_bus::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;

/// The module's confidential authorities are process-owned and intentionally
/// survive service-tree replacement. Run actual bus fixtures in a child test
/// process so Windows' linked module loader cannot share those owners between
/// independent broker instances in the same libtest process.
fn isolate_process_owned_fixture(
test_filter: &str,
marker: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
const CHILD_MARKER: &str = "TINYSECURITY_PROCESS_OWNED_FIXTURE";
if std::env::var(CHILD_MARKER).is_ok_and(|value| value == marker) {
return Ok(false);
}
let status = std::process::Command::new(std::env::current_exe()?)
.arg(test_filter)
.arg("--nocapture")
.env(CHILD_MARKER, marker)
.status()?;
if !status.success() {
return Err(format!("isolated fixture process exited with {status}").into());
}
Ok(true)
}
#[derive(Debug)]
struct FixedClock(std::sync::atomic::AtomicU64);
impl tinysecurity::approval::Clock for FixedClock {
Expand Down Expand Up @@ -922,6 +945,12 @@ mod linked_fixture {
#[tokio::test]
async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed()
-> TestResult {
if isolate_process_owned_fixture(
"actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed",
"master",
)? {
return Ok(());
}
for mode in 0..=19 {
let bus = MemoryBus::new();
let owner = Broker::new();
Expand Down
Loading
Loading