diff --git a/crates/nono-cli/data/nono-profile.schema.json b/crates/nono-cli/data/nono-profile.schema.json index 84c42f67c..112289a72 100644 --- a/crates/nono-cli/data/nono-profile.schema.json +++ b/crates/nono-cli/data/nono-profile.schema.json @@ -1384,16 +1384,30 @@ "InterceptRuleConfig": { "type": "object", "additionalProperties": false, - "required": ["args"], + "oneOf": [{ "required": ["args"] }, { "required": ["match"] }], "properties": { "args": { "type": "array", "items": { "type": "string" }, "description": "Contiguous argument sequence matched within argv[1..]. An empty list is a catch-all and must be last. Broad single-argument rules such as [\"--force\"] match anywhere in the invocation, not only at the subcommand position." }, + "match": { "$ref": "#/$defs/InterceptRuleMatchConfig" }, "action": { "$ref": "#/$defs/InterceptActionConfig" } } }, + "InterceptRuleMatchConfig": { + "type": "object", + "additionalProperties": false, + "anyOf": [{ "required": ["argv"] }, { "required": ["env"] }], + "properties": { + "argv": { "$ref": "#/$defs/InterceptArgvMatcherConfig" }, + "env": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/EnvMatcherConfig" } + } + } + }, "InterceptActionConfig": { "oneOf": [ { @@ -1694,6 +1708,47 @@ } } }, + "InterceptArgvMatcherConfig": { + "type": "object", + "additionalProperties": false, + "oneOf": [ + { + "required": ["exact"], + "not": { + "anyOf": [{ "required": ["prefix"] }, { "required": ["contains"] }] + } + }, + { + "required": ["prefix"], + "not": { + "anyOf": [{ "required": ["exact"] }, { "required": ["contains"] }] + } + }, + { + "required": ["contains"], + "not": { + "anyOf": [{ "required": ["exact"] }, { "required": ["prefix"] }] + } + } + ], + "properties": { + "exact": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + }, + "prefix": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + }, + "contains": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + } + } + }, "EnvMatcherConfig": { "type": "object", "additionalProperties": false, diff --git a/crates/nono-cli/src/command_policy.rs b/crates/nono-cli/src/command_policy.rs index b4646bcbf..f334254dd 100644 --- a/crates/nono-cli/src/command_policy.rs +++ b/crates/nono-cli/src/command_policy.rs @@ -490,15 +490,21 @@ pub enum InterceptActionConfig { /// A sub-command mediation rule on a [`CommandPolicyConfig`]. /// -/// Rules are evaluated in order; the first match wins. An empty `args` list -/// is a catch-all and must appear last in the list. If no rule matches the -/// default action is `Passthrough`. +/// Rules are evaluated in order; the first match wins. Legacy `args` matches a +/// contiguous argument sequence. Predicate `match` rules can match argv and/or +/// env. An explicit empty `args` list is a catch-all and must appear last in the +/// list. If no rule matches the default action is `Passthrough`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct InterceptRuleConfig { /// Contiguous argument sequence to match within argv[1..] of the shim invocation. /// An empty list is a catch-all. - pub args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option>, + /// Explicit predicate match for argv and/or env. Serialized as `match` in + /// profile JSON because that is the user-facing policy term. + #[serde(rename = "match", default, skip_serializing_if = "Option::is_none")] + pub match_config: Option, /// Action to take when this rule matches. #[serde(default)] pub action: InterceptActionConfig, @@ -510,6 +516,15 @@ pub struct InterceptRuleConfig { pub sandbox: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct InterceptRuleMatchConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub argv: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CommandPolicyConfig { @@ -1506,8 +1521,28 @@ fn validate_intercept_rules( ), ); } - if rule.args.is_empty() { - saw_catch_all = true; + let label = format!("commands.{command_name}.intercept[{i}]"); + match (&rule.args, &rule.match_config) { + (Some(_), Some(_)) => { + report.error( + "invalid_intercept_matcher", + format!("{label} must define either args or match, not both"), + ); + } + (None, None) => { + report.error( + "invalid_intercept_matcher", + format!("{label} must define args or match"), + ); + } + (Some(args), None) => { + if args.is_empty() { + saw_catch_all = true; + } + } + (None, Some(match_config)) => { + validate_intercept_matcher(&label, match_config, report); + } } if let InterceptActionConfig::Respond { stdout } = &rule.action && stdout.len() > 1024 * 1024 @@ -1573,6 +1608,23 @@ fn validate_intercept_rules( } } +fn validate_intercept_matcher( + label: &str, + matcher: &InterceptRuleMatchConfig, + report: &mut CommandPolicyValidationReport, +) { + if matcher.argv.is_none() && matcher.env.is_empty() { + report.error( + "invalid_intercept_matcher", + format!("{label}.match must define argv or env"), + ); + } + if let Some(argv) = &matcher.argv { + validate_argv_matcher(label, "invalid_intercept_matcher", argv, true, report); + } + validate_env_matchers(label, "invalid_intercept_env_matcher", &matcher.env, report); +} + /// Validate an `exec` intercept action's `command`. /// /// Config-level checks only: non-empty, NUL-free, `command[0]` is @@ -2027,47 +2079,75 @@ fn validate_invocation_rule( validate_positive_timeout(label, rule.timeout_secs, report); if let Some(argv) = &rule.argv { - let matcher_count = usize::from(argv.exact.is_some()) - + usize::from(argv.prefix.is_some()) - + usize::from(argv.contains.is_some()); - if matcher_count != 1 { - report.error( - "invalid_invocation_matcher", - format!("{label} must define exactly one argv matcher"), - ); + validate_argv_matcher(label, "invalid_invocation_matcher", argv, false, report); + } + + validate_env_matchers(label, "invalid_invocation_env_matcher", &rule.env, report); +} + +fn validate_argv_matcher( + label: &str, + code: &'static str, + argv: &ArgvMatcherConfig, + reject_empty_matcher: bool, + report: &mut CommandPolicyValidationReport, +) { + let matcher_count = usize::from(argv.exact.is_some()) + + usize::from(argv.prefix.is_some()) + + usize::from(argv.contains.is_some()); + if matcher_count != 1 { + report.error( + code, + format!("{label} must define exactly one argv matcher"), + ); + } + for value in argv + .exact + .iter() + .chain(argv.prefix.iter()) + .chain(argv.contains.iter()) + .flat_map(|values| values.iter()) + { + if value.contains('\0') { + report.error(code, format!("{label} argv matcher contains NUL")); } - for value in argv + } + if reject_empty_matcher { + for values in argv .exact .iter() .chain(argv.prefix.iter()) .chain(argv.contains.iter()) - .flat_map(|values| values.iter()) { - if value.contains('\0') { - report.error( - "invalid_invocation_matcher", - format!("{label} argv matcher contains NUL"), - ); + if values.is_empty() { + report.error(code, format!("{label} argv matcher cannot be empty")); } } } +} - for (name, matcher) in &rule.env { +fn validate_env_matchers( + label: &str, + code: &'static str, + env: &BTreeMap, + report: &mut CommandPolicyValidationReport, +) { + for (name, matcher) in env { if !valid_env_matcher_name(name) { report.error( - "invalid_invocation_env_matcher", + code, format!("{label} env matcher has invalid name '{name}'"), ); } if matcher.equals.is_none() && matcher.one_of.is_empty() { report.error( - "invalid_invocation_env_matcher", + code, format!("{label} env matcher for '{name}' must define equals or one_of"), ); } if matcher.equals.is_some() && !matcher.one_of.is_empty() { report.error( - "invalid_invocation_env_matcher", + code, format!("{label} env matcher for '{name}' cannot define both equals and one_of"), ); } @@ -4266,7 +4346,8 @@ mod tests { let mut config = active_git_config(); let git = config.commands.get_mut("git").expect("git command"); git.intercept.push(InterceptRuleConfig { - args: vec!["auth".to_string(), "switch".to_string()], + args: Some(vec!["auth".to_string(), "switch".to_string()]), + match_config: None, action: InterceptActionConfig::Exec { command }, sandbox: None, }); @@ -4377,7 +4458,8 @@ mod tests { ); let git = config.commands.get_mut("git").expect("git command"); git.intercept.push(InterceptRuleConfig { - args: vec!["auth".to_string(), "token".to_string()], + args: Some(vec!["auth".to_string(), "token".to_string()]), + match_config: None, action: InterceptActionConfig::CaptureCredential { credential: "github".to_string(), grant_to: vec![], @@ -4406,7 +4488,8 @@ mod tests { ); let git = config.commands.get_mut("git").expect("git command"); git.intercept.push(InterceptRuleConfig { - args: vec!["auth".to_string(), "token".to_string()], + args: Some(vec!["auth".to_string(), "token".to_string()]), + match_config: None, action: InterceptActionConfig::CaptureCredential { credential: "agent".to_string(), grant_to: vec![], @@ -4426,12 +4509,14 @@ mod tests { #[test] fn intercept_rule_merge_child_appends_child_rules() { let parent_rule = InterceptRuleConfig { - args: vec!["push".to_string()], + args: Some(vec!["push".to_string()]), + match_config: None, action: InterceptActionConfig::Approve { timeout_secs: None }, sandbox: None, }; let child_rule = InterceptRuleConfig { - args: vec!["fetch".to_string()], + args: Some(vec!["fetch".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: None, }; @@ -4450,7 +4535,8 @@ mod tests { #[test] fn intercept_rule_merge_child_does_not_duplicate() { let rule = InterceptRuleConfig { - args: vec!["push".to_string()], + args: Some(vec!["push".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: None, }; @@ -4472,12 +4558,14 @@ mod tests { if let Some(git) = config.commands.get_mut("git") { git.intercept = vec![ InterceptRuleConfig { - args: vec![], + args: Some(vec![]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: None, }, InterceptRuleConfig { - args: vec!["push".to_string()], + args: Some(vec!["push".to_string()]), + match_config: None, action: InterceptActionConfig::Approve { timeout_secs: None }, sandbox: None, }, @@ -4500,12 +4588,14 @@ mod tests { if let Some(git) = config.commands.get_mut("git") { git.intercept = vec![ InterceptRuleConfig { - args: vec!["push".to_string()], + args: Some(vec!["push".to_string()]), + match_config: None, action: InterceptActionConfig::Approve { timeout_secs: None }, sandbox: None, }, InterceptRuleConfig { - args: vec![], + args: Some(vec![]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: None, }, @@ -4522,12 +4612,286 @@ mod tests { ); } + #[test] + fn validate_intercept_match_only_rule_is_valid() { + let mut config = active_git_config(); + if let Some(git) = config.commands.get_mut("git") { + git.intercept = vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: None, + prefix: None, + contains: Some(vec!["push".to_string(), "--force".to_string()]), + }), + env: BTreeMap::from([( + "GIT_SSH_COMMAND".to_string(), + EnvMatcherConfig { + one_of: Vec::new(), + equals: Some("ssh -i /tmp/fake_key".to_string()), + }, + )]), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }]; + } + + let report = + validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved); + + assert!( + !report + .errors + .iter() + .any(|f| f.code.starts_with("invalid_intercept")), + "unexpected intercept matcher validation error: {:?}", + report.errors + ); + } + + #[test] + fn validate_intercept_rejects_args_and_match_together() { + let mut config = active_git_config(); + if let Some(git) = config.commands.get_mut("git") { + git.intercept = vec![InterceptRuleConfig { + args: Some(vec!["push".to_string()]), + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: None, + prefix: Some(vec!["push".to_string()]), + contains: None, + }), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Passthrough, + sandbox: None, + }]; + } + + let report = + validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved); + + assert!( + report + .errors + .iter() + .any(|f| f.code == "invalid_intercept_matcher" + && f.message.contains("either args or match")), + "expected args+match rejection: {:?}", + report.errors + ); + } + + #[test] + fn validate_intercept_rejects_missing_matcher() { + let mut config = active_git_config(); + if let Some(git) = config.commands.get_mut("git") { + git.intercept = vec![InterceptRuleConfig { + args: None, + match_config: None, + action: InterceptActionConfig::Passthrough, + sandbox: None, + }]; + } + + let report = + validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved); + + assert!( + report + .errors + .iter() + .any(|f| f.code == "invalid_intercept_matcher" + && f.message.contains("must define args or match")), + "expected missing matcher rejection: {:?}", + report.errors + ); + } + + #[test] + fn validate_intercept_rejects_empty_match_object() { + let mut config = active_git_config(); + if let Some(git) = config.commands.get_mut("git") { + git.intercept = vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig::default()), + action: InterceptActionConfig::Passthrough, + sandbox: None, + }]; + } + + let report = + validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved); + + assert!( + report + .errors + .iter() + .any(|f| f.code == "invalid_intercept_matcher" + && f.message.contains("must define argv or env")), + "expected empty match rejection: {:?}", + report.errors + ); + } + + #[test] + fn validate_intercept_reuses_argv_and_env_matcher_validation() { + let mut config = active_git_config(); + if let Some(git) = config.commands.get_mut("git") { + git.intercept = vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: Some(vec!["push".to_string()]), + prefix: Some(vec!["push".to_string()]), + contains: None, + }), + env: BTreeMap::from([( + "BAD=NAME".to_string(), + EnvMatcherConfig { + one_of: Vec::new(), + equals: None, + }, + )]), + }), + action: InterceptActionConfig::Passthrough, + sandbox: None, + }]; + } + + let report = + validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved); + + assert!( + report + .errors + .iter() + .any(|f| f.code == "invalid_intercept_matcher" + && f.message.contains("exactly one argv matcher")), + "expected argv matcher validation: {:?}", + report.errors + ); + assert!( + report + .errors + .iter() + .any(|f| f.code == "invalid_intercept_env_matcher" + && f.message.contains("invalid name")), + "expected env name validation: {:?}", + report.errors + ); + assert!( + report + .errors + .iter() + .any(|f| f.code == "invalid_intercept_env_matcher" + && f.message.contains("must define equals or one_of")), + "expected env matcher validation: {:?}", + report.errors + ); + } + + #[test] + fn validate_intercept_rejects_empty_argv_matcher() { + for argv in [ + ArgvMatcherConfig { + exact: Some(Vec::new()), + prefix: None, + contains: None, + }, + ArgvMatcherConfig { + exact: None, + prefix: Some(Vec::new()), + contains: None, + }, + ArgvMatcherConfig { + exact: None, + prefix: None, + contains: Some(Vec::new()), + }, + ] { + let mut config = active_git_config(); + if let Some(git) = config.commands.get_mut("git") { + git.intercept = vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(argv), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Passthrough, + sandbox: None, + }]; + } + + let report = + validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved); + + assert!( + report + .errors + .iter() + .any(|f| f.code == "invalid_intercept_matcher" + && f.message.contains("cannot be empty")), + "expected empty argv matcher validation: {:?}", + report.errors + ); + } + } + + #[test] + fn validate_invocation_allows_empty_argv_matcher_for_compatibility() { + for argv in [ + ArgvMatcherConfig { + exact: Some(Vec::new()), + prefix: None, + contains: None, + }, + ArgvMatcherConfig { + exact: None, + prefix: Some(Vec::new()), + contains: None, + }, + ArgvMatcherConfig { + exact: None, + prefix: None, + contains: Some(Vec::new()), + }, + ] { + let mut report = CommandPolicyValidationReport::default(); + let rule = InvocationRuleConfig { + argv: Some(argv), + env: BTreeMap::new(), + backend: None, + reason: None, + timeout_secs: None, + }; + validate_invocation_rule( + "commands.git.from.session.invocation_policy.approve[0]", + "approve", + &rule, + &CommandPoliciesConfig::default(), + &mut report, + ); + + assert!( + !report + .errors + .iter() + .any(|f| f.code == "invalid_invocation_matcher"), + "empty invocation argv matcher must remain compatible: {:?}", + report.errors + ); + } + } + #[test] fn validate_intercept_respond_stdout_too_large() { let mut config = active_git_config(); if let Some(git) = config.commands.get_mut("git") { git.intercept = vec![InterceptRuleConfig { - args: vec![], + args: Some(vec![]), + match_config: None, action: InterceptActionConfig::Respond { stdout: "x".repeat(1024 * 1024 + 1), }, @@ -4580,7 +4944,8 @@ mod tests { #[test] fn intercept_rule_omits_absent_sandbox_override() { let rule = InterceptRuleConfig { - args: vec!["status".to_string()], + args: Some(vec!["status".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: None, }; @@ -4591,6 +4956,45 @@ mod tests { ); } + #[test] + fn intercept_match_rule_omits_absent_predicate_fields() { + let argv_rule = InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: None, + prefix: Some(vec!["push".to_string()]), + contains: None, + }), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }; + let argv_json = serde_json::to_value(&argv_rule).expect("serialize argv-only match rule"); + assert!(argv_json["match"].get("argv").is_some()); + assert!(argv_json["match"].get("env").is_none()); + + let env_rule = InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: None, + env: BTreeMap::from([( + "GIT_SSH_COMMAND".to_string(), + EnvMatcherConfig { + one_of: Vec::new(), + equals: Some("ssh -i /tmp/fake_key".to_string()), + }, + )]), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }; + let env_json = serde_json::to_value(&env_rule).expect("serialize env-only match rule"); + assert!(env_json["match"].get("argv").is_none()); + assert!(env_json["match"].get("env").is_some()); + } + #[test] fn nested_unsafe_seatbelt_rules_finds_command_from_and_intercept_sandboxes() { let mut policies = CommandPoliciesConfig::default(); @@ -4609,7 +5013,8 @@ mod tests { })), )]), intercept: vec![InterceptRuleConfig { - args: vec!["push".to_string()], + args: Some(vec!["push".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: Some(CommandSandboxConfig { unsafe_macos_seatbelt_rules: vec!["(allow intercept-sandbox)".to_string()], @@ -4662,7 +5067,8 @@ mod tests { })), )]), intercept: vec![InterceptRuleConfig { - args: vec!["push".to_string()], + args: Some(vec!["push".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: Some(CommandSandboxConfig { unsafe_macos_seatbelt_rules: vec!["(allow intercept-sandbox)".to_string()], @@ -4699,7 +5105,8 @@ mod tests { ); let git = config.commands.get_mut("git").expect("git command"); git.intercept.push(InterceptRuleConfig { - args: vec!["status".to_string()], + args: Some(vec!["status".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: Some(CommandSandboxConfig { fs_read: vec![".".to_string()], @@ -4719,7 +5126,8 @@ mod tests { let mut config = active_git_config(); let git = config.commands.get_mut("git").expect("git command"); git.intercept.push(InterceptRuleConfig { - args: vec!["status".to_string()], + args: Some(vec!["status".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: Some(CommandSandboxConfig { use_credentials: vec!["does-not-exist".to_string()], @@ -4746,7 +5154,8 @@ mod tests { let mut config = active_git_config(); let git = config.commands.get_mut("git").expect("git command"); git.intercept.push(InterceptRuleConfig { - args: vec!["status".to_string()], + args: Some(vec!["status".to_string()]), + match_config: None, action: InterceptActionConfig::Respond { stdout: String::new(), }, @@ -4783,7 +5192,8 @@ mod tests { // capture_credential launches the real binary, so a sandbox override is // meaningful and must be accepted. git.intercept.push(InterceptRuleConfig { - args: vec!["status".to_string()], + args: Some(vec!["status".to_string()]), + match_config: None, action: InterceptActionConfig::CaptureCredential { credential: "github".to_string(), grant_to: Vec::new(), @@ -4830,7 +5240,8 @@ mod tests { if let Some(git) = config.commands.get_mut("git") { git.sandbox = None; git.intercept = vec![InterceptRuleConfig { - args: vec!["push".to_string()], + args: Some(vec!["push".to_string()]), + match_config: None, action: InterceptActionConfig::Approve { timeout_secs: Some(0), }, diff --git a/crates/nono-cli/src/profile_save_runtime.rs b/crates/nono-cli/src/profile_save_runtime.rs index 9a64f2c4b..3eaa19890 100644 --- a/crates/nono-cli/src/profile_save_runtime.rs +++ b/crates/nono-cli/src/profile_save_runtime.rs @@ -1958,7 +1958,8 @@ mod tests { "git".to_string(), CommandPolicyConfig { intercept: vec![InterceptRuleConfig { - args: vec!["push".to_string()], + args: Some(vec!["push".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: Some(CommandSandboxConfig { unsafe_macos_seatbelt_rules: vec!["(allow iokit-open)".to_string()], diff --git a/crates/nono-cli/src/tool-sandbox/platform/linux.rs b/crates/nono-cli/src/tool-sandbox/platform/linux.rs index cef3c3b76..f975e4ee3 100644 --- a/crates/nono-cli/src/tool-sandbox/platform/linux.rs +++ b/crates/nono-cli/src/tool-sandbox/platform/linux.rs @@ -1525,9 +1525,31 @@ fn handle_shim_stream_inner( // Resolve intercept action before consuming the active-count slot so // that Respond can return without forking a child process. let command_config = state.plan.config.commands.get(&request.command); - let intercept = command_config - .map(|cc| super::resolve_intercept_action(cc, &request.argv)) - .unwrap_or_else(super::ResolvedInterceptAction::passthrough); + let intercept = match command_config { + Some(cc) => { + match super::resolve_intercept_action(cc, &request.argv, || { + filter_child_env(state, &request, policy) + }) { + Ok(intercept) => intercept, + Err(err) => { + record_command_policy_audit( + audit_recorder.as_ref(), + &request, + &state.redaction_policy, + session_id, + auth.peer_pid, + session_root_pid, + Some(&caller), + "denied", + Some(err.to_string()), + None, + )?; + return Err(err); + } + } + } + None => super::ResolvedInterceptAction::passthrough(), + }; let intercept_action = intercept.action; // A matched intercept rule may carry a sandbox that replaces the command's @@ -5559,7 +5581,8 @@ mod tests { "tool".to_string(), CommandPolicyConfig { intercept: vec![InterceptRuleConfig { - args: vec![], + args: Some(vec![]), + match_config: None, action: InterceptActionConfig::Exec { command: vec![helper.to_string_lossy().into_owned()], }, diff --git a/crates/nono-cli/src/tool-sandbox/platform/macos.rs b/crates/nono-cli/src/tool-sandbox/platform/macos.rs index 617a163a4..d7fe0a8ac 100644 --- a/crates/nono-cli/src/tool-sandbox/platform/macos.rs +++ b/crates/nono-cli/src/tool-sandbox/platform/macos.rs @@ -1209,7 +1209,26 @@ fn handle_shim_stream_inner( NonoError::SandboxInit(format!("missing command config for {}", request.command)) })?; - let intercept = super::resolve_intercept_action(command_config, &request.argv); + let intercept = match super::resolve_intercept_action(command_config, &request.argv, || { + filter_child_env(state, &request, policy) + }) { + Ok(intercept) => intercept, + Err(err) => { + record_command_policy_audit( + audit_recorder.as_ref(), + &request, + &state.redaction_policy, + session_id, + auth.peer_pid, + session_root_pid, + Some(&caller), + "denied", + Some(err.to_string()), + None, + )?; + return Err(err); + } + }; let intercept_action = intercept.action; // A matched intercept rule may carry a sandbox that replaces the command's @@ -4912,7 +4931,8 @@ mod tests { "tool".to_string(), CommandPolicyConfig { intercept: vec![InterceptRuleConfig { - args: vec![], + args: Some(vec![]), + match_config: None, action: InterceptActionConfig::Exec { command: vec![helper.to_string_lossy().into_owned()], }, @@ -5483,7 +5503,7 @@ mod tests { let with_override = crate::tool_sandbox::ResolvedInterceptAction { action: &crate::command_policy::InterceptActionConfig::Passthrough, - rule_args: Some(&[]), + rule_label: Some(crate::tool_sandbox::ResolvedInterceptRuleLabel::Args(&[])), sandbox: Some(&override_sandbox), }; let effective = with_override.sandbox.unwrap_or(&command_sandbox); diff --git a/crates/nono-cli/src/tool-sandbox/policy.rs b/crates/nono-cli/src/tool-sandbox/policy.rs index f18070372..6686a173a 100644 --- a/crates/nono-cli/src/tool-sandbox/policy.rs +++ b/crates/nono-cli/src/tool-sandbox/policy.rs @@ -4,26 +4,36 @@ static PASSTHROUGH_INTERCEPT_ACTION: crate::command_policy::InterceptActionConfi #[cfg(any(target_os = "linux", target_os = "macos"))] pub(super) struct ResolvedInterceptAction<'a> { pub(super) action: &'a crate::command_policy::InterceptActionConfig, - pub(super) rule_args: Option<&'a [String]>, + pub(super) rule_label: Option>, /// Per-rule sandbox override for this matched invocation (passthrough). /// `None` for the fallthrough and rules without an override. pub(super) sandbox: Option<&'a crate::command_policy::CommandSandboxConfig>, } +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Clone, Copy)] +pub(super) enum ResolvedInterceptRuleLabel<'a> { + Args(&'a [String]), + Predicate(usize), +} + #[cfg(any(target_os = "linux", target_os = "macos"))] impl<'a> ResolvedInterceptAction<'a> { pub(super) fn passthrough() -> Self { Self { action: &PASSTHROUGH_INTERCEPT_ACTION, - rule_args: None, + rule_label: None, sandbox: None, } } pub(super) fn rule_label(&self) -> String { - match self.rule_args { - Some([]) => "".to_string(), - Some(args) => args.join(" "), + match self.rule_label { + Some(ResolvedInterceptRuleLabel::Args([])) => "".to_string(), + Some(ResolvedInterceptRuleLabel::Args(args)) => args.join(" "), + Some(ResolvedInterceptRuleLabel::Predicate(index)) => { + format!("intercept[{index}].match") + } None => "passthrough".to_string(), } } @@ -33,39 +43,128 @@ impl<'a> ResolvedInterceptAction<'a> { pub(super) fn resolve_intercept_action<'a>( command_config: &'a crate::command_policy::CommandPolicyConfig, argv: &[Vec], -) -> ResolvedInterceptAction<'a> { + mut env_supplier: impl FnMut() -> nono::Result>>, +) -> nono::Result> { // argv[0] is the synthesised command name; match against argv[1..]. let shim_args: Vec<&[u8]> = argv.iter().skip(1).map(|v| v.as_slice()).collect(); - - for rule in &command_config.intercept { - if rule.args.is_empty() { - return ResolvedInterceptAction { - action: &rule.action, - rule_args: Some(&rule.args), - sandbox: rule.sandbox.as_ref(), - }; + let mut predicate_env = None; + + for (index, rule) in command_config.intercept.iter().enumerate() { + if let Some(args) = &rule.args { + if intercept_args_match(args, &shim_args) { + return Ok(ResolvedInterceptAction { + action: &rule.action, + rule_label: Some(ResolvedInterceptRuleLabel::Args(args)), + sandbox: rule.sandbox.as_ref(), + }); + } + continue; } - if intercept_args_match(&rule.args, &shim_args) { - return ResolvedInterceptAction { + + if let Some(match_config) = &rule.match_config + && intercept_match_config_matches( + match_config, + &shim_args, + &mut predicate_env, + &mut env_supplier, + )? + { + return Ok(ResolvedInterceptAction { action: &rule.action, - rule_args: Some(&rule.args), + rule_label: Some(ResolvedInterceptRuleLabel::Predicate(index)), sandbox: rule.sandbox.as_ref(), - }; + }); } } - ResolvedInterceptAction::passthrough() + Ok(ResolvedInterceptAction::passthrough()) } fn intercept_args_match(expected_args: &[String], shim_args: &[&[u8]]) -> bool { - expected_args.is_empty() - || (shim_args.len() >= expected_args.len() - && shim_args.windows(expected_args.len()).any(|window| { - expected_args + if expected_args.is_empty() { + return true; + } + if shim_args.len() < expected_args.len() { + return false; + } + shim_args.windows(expected_args.len()).any(|window| { + expected_args + .iter() + .zip(window.iter()) + .all(|(expected, actual)| expected.as_bytes() == *actual) + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn intercept_match_config_matches( + matcher: &crate::command_policy::InterceptRuleMatchConfig, + args: &[&[u8]], + env: &mut Option>, + env_supplier: &mut impl FnMut() -> nono::Result>>, +) -> nono::Result { + if let Some(argv_matcher) = &matcher.argv + && !intercept_argv_matcher_matches(argv_matcher, args) + { + return Ok(false); + } + if !matcher.env.is_empty() { + if env.is_none() { + let supplied_env = env_supplier()?; + *env = Some(invocation_env(&supplied_env)?); + } + let Some(predicate_env) = env.as_ref() else { + return Ok(false); + }; + if !env_matchers_match(&matcher.env, predicate_env) { + return Ok(false); + } + } + Ok(matcher.argv.is_some() || !matcher.env.is_empty()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn intercept_argv_matcher_matches( + matcher: &crate::command_policy::ArgvMatcherConfig, + args: &[&[u8]], +) -> bool { + if let Some(exact) = &matcher.exact { + if exact.is_empty() { + return false; + } + return args.len() == exact.len() + && exact + .iter() + .zip(args.iter()) + .all(|(expected, actual)| expected.as_bytes() == *actual); + } + if let Some(prefix) = &matcher.prefix { + if prefix.is_empty() { + return false; + } + return args.len() >= prefix.len() + && prefix + .iter() + .zip(args.iter()) + .all(|(expected, actual)| expected.as_bytes() == *actual); + } + if let Some(contains) = &matcher.contains { + if contains.is_empty() { + return false; + } + return args.len() >= contains.len() + && args.windows(contains.len()).any(|window| { + contains .iter() .zip(window.iter()) .all(|(expected, actual)| expected.as_bytes() == *actual) - })) + }); + } + false +} + +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +fn empty_intercept_env() -> nono::Result>> { + Ok(Vec::new()) } /// Resolve the env-expanded helper path and forwarded extra args for an `exec` @@ -395,7 +494,15 @@ fn invocation_rule_matches( { return false; } - for (name, matcher) in &rule.env { + env_matchers_match(&rule.env, env) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn env_matchers_match( + matchers: &std::collections::BTreeMap, + env: &std::collections::BTreeMap, +) -> bool { + for (name, matcher) in matchers { let Some(value) = env.get(name) else { return false; }; @@ -568,8 +675,8 @@ mod intercept_tests { use crate::command_policy::{ ApprovalBackendConfig, ApprovalBackendType, ArgvMatcherConfig, CommandPoliciesConfig, CommandPolicyConfig, CommandResourceConfig, CommandSandboxConfig, EnvMatcherConfig, - InterceptActionConfig, InterceptRuleConfig, InvocationPolicyConfig, InvocationRuleConfig, - PolicyDecision, PolicyDecisionConfig, + InterceptActionConfig, InterceptRuleConfig, InterceptRuleMatchConfig, + InvocationPolicyConfig, InvocationRuleConfig, PolicyDecision, PolicyDecisionConfig, }; use std::collections::BTreeMap; @@ -577,7 +684,8 @@ mod intercept_tests { fn resolve_intercept_action_tracks_matched_rule_label() { let config = CommandPolicyConfig { intercept: vec![InterceptRuleConfig { - args: vec!["push".to_string(), "--force".to_string()], + args: Some(vec!["push".to_string(), "--force".to_string()]), + match_config: None, action: InterceptActionConfig::Approve { timeout_secs: None }, sandbox: None, }], @@ -585,7 +693,8 @@ mod intercept_tests { }; let argv = vec![b"git".to_vec(), b"push".to_vec(), b"--force".to_vec()]; - let resolved = resolve_intercept_action(&config, &argv); + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); assert_eq!(resolved.rule_label(), "push --force"); assert!(matches!( @@ -598,7 +707,8 @@ mod intercept_tests { fn resolve_intercept_action_matches_after_leading_global_args() { let config = CommandPolicyConfig { intercept: vec![InterceptRuleConfig { - args: vec!["push".to_string(), "--force".to_string()], + args: Some(vec!["push".to_string(), "--force".to_string()]), + match_config: None, action: InterceptActionConfig::Approve { timeout_secs: None }, sandbox: None, }], @@ -612,7 +722,8 @@ mod intercept_tests { b"--force".to_vec(), ]; - let resolved = resolve_intercept_action(&config, &argv); + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); assert_eq!(resolved.rule_label(), "push --force"); assert!(matches!( @@ -625,7 +736,8 @@ mod intercept_tests { fn resolve_intercept_action_falls_through_when_rule_sequence_is_absent() { let config = CommandPolicyConfig { intercept: vec![InterceptRuleConfig { - args: vec!["push".to_string(), "--force".to_string()], + args: Some(vec!["push".to_string(), "--force".to_string()]), + match_config: None, action: InterceptActionConfig::Approve { timeout_secs: None }, sandbox: None, }], @@ -639,7 +751,8 @@ mod intercept_tests { b"--force".to_vec(), ]; - let resolved = resolve_intercept_action(&config, &argv); + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); assert_eq!(resolved.rule_label(), "passthrough"); assert!(matches!( @@ -648,11 +761,352 @@ mod intercept_tests { )); } + #[test] + fn resolve_intercept_action_matches_argv_contains_predicate() { + let config = CommandPolicyConfig { + intercept: vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: None, + prefix: None, + contains: Some(vec!["push".to_string(), "--force".to_string()]), + }), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }], + ..CommandPolicyConfig::default() + }; + let argv = vec![ + b"git".to_vec(), + b"-c".to_vec(), + b"foo=bar".to_vec(), + b"push".to_vec(), + b"--force".to_vec(), + ]; + + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); + + assert_eq!(resolved.rule_label(), "intercept[0].match"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Approve { .. } + )); + } + + #[test] + fn resolve_intercept_action_matches_argv_prefix_predicate() { + let config = CommandPolicyConfig { + intercept: vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: None, + prefix: Some(vec!["push".to_string()]), + contains: None, + }), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }], + ..CommandPolicyConfig::default() + }; + let argv = vec![b"git".to_vec(), b"push".to_vec(), b"--force".to_vec()]; + + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); + + assert_eq!(resolved.rule_label(), "intercept[0].match"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Approve { .. } + )); + } + + #[test] + fn resolve_intercept_action_exact_predicate_rejects_extra_args() { + let config = CommandPolicyConfig { + intercept: vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: Some(vec!["push".to_string(), "--force".to_string()]), + prefix: None, + contains: None, + }), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }], + ..CommandPolicyConfig::default() + }; + let argv = vec![ + b"git".to_vec(), + b"-c".to_vec(), + b"foo=bar".to_vec(), + b"push".to_vec(), + b"--force".to_vec(), + ]; + + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); + + assert_eq!(resolved.rule_label(), "passthrough"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Passthrough + )); + } + + #[test] + fn resolve_intercept_action_empty_argv_predicates_fall_through() { + for argv_matcher in [ + ArgvMatcherConfig { + exact: Some(Vec::new()), + prefix: None, + contains: None, + }, + ArgvMatcherConfig { + exact: None, + prefix: Some(Vec::new()), + contains: None, + }, + ArgvMatcherConfig { + exact: None, + prefix: None, + contains: Some(Vec::new()), + }, + ] { + let config = CommandPolicyConfig { + intercept: vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(argv_matcher), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }], + ..CommandPolicyConfig::default() + }; + let argv = vec![b"git".to_vec(), b"push".to_vec(), b"--force".to_vec()]; + + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); + + assert_eq!(resolved.rule_label(), "passthrough"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Passthrough + )); + } + } + + #[test] + fn resolve_intercept_action_matches_env_predicate() { + let config = CommandPolicyConfig { + intercept: vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: None, + env: BTreeMap::from([( + "GIT_SSH_COMMAND".to_string(), + EnvMatcherConfig { + one_of: Vec::new(), + equals: Some("ssh -i /tmp/fake_key".to_string()), + }, + )]), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }], + ..CommandPolicyConfig::default() + }; + let argv = vec![b"git".to_vec(), b"push".to_vec()]; + let env = vec![b"GIT_SSH_COMMAND=ssh -i /tmp/fake_key".to_vec()]; + + let resolved = resolve_intercept_action(&config, &argv, || Ok(env.clone())) + .expect("resolve intercept"); + + assert_eq!(resolved.rule_label(), "intercept[0].match"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Approve { .. } + )); + } + + #[test] + fn resolve_intercept_action_env_predicate_falls_through_when_missing() { + let config = CommandPolicyConfig { + intercept: vec![InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: None, + env: BTreeMap::from([( + "GIT_SSH_COMMAND".to_string(), + EnvMatcherConfig { + one_of: Vec::new(), + equals: Some("ssh -i /tmp/fake_key".to_string()), + }, + )]), + }), + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }], + ..CommandPolicyConfig::default() + }; + let argv = vec![b"git".to_vec(), b"push".to_vec()]; + + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); + + assert_eq!(resolved.rule_label(), "passthrough"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Passthrough + )); + } + + #[test] + fn resolve_intercept_action_stops_before_later_env_predicate() { + let config = CommandPolicyConfig { + intercept: vec![ + InterceptRuleConfig { + args: Some(vec!["push".to_string()]), + match_config: None, + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }, + InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: None, + env: BTreeMap::from([( + "GIT_SSH_COMMAND".to_string(), + EnvMatcherConfig { + one_of: Vec::new(), + equals: Some("ssh -i /tmp/fake_key".to_string()), + }, + )]), + }), + action: InterceptActionConfig::Respond { + stdout: "env match".to_string(), + }, + sandbox: None, + }, + ], + ..CommandPolicyConfig::default() + }; + let argv = vec![b"git".to_vec(), b"push".to_vec()]; + let mut env_requested = false; + + let resolved = resolve_intercept_action(&config, &argv, || { + env_requested = true; + Err(nono::NonoError::SandboxInit( + "env should not be read".to_string(), + )) + }) + .expect("resolve intercept"); + + assert!(!env_requested); + assert_eq!(resolved.rule_label(), "push"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Approve { .. } + )); + } + + #[test] + fn resolve_intercept_action_stops_before_later_argv_predicate() { + let config = CommandPolicyConfig { + intercept: vec![ + InterceptRuleConfig { + args: Some(vec!["push".to_string()]), + match_config: None, + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }, + InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: Some(vec!["status".to_string()]), + prefix: None, + contains: None, + }), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Respond { + stdout: "argv match".to_string(), + }, + sandbox: None, + }, + ], + ..CommandPolicyConfig::default() + }; + let argv = vec![b"git".to_vec(), b"push".to_vec(), vec![0xff]]; + + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); + + assert_eq!(resolved.rule_label(), "push"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Approve { .. } + )); + } + + #[test] + fn resolve_intercept_action_non_utf8_argv_predicate_falls_through() { + let config = CommandPolicyConfig { + intercept: vec![ + InterceptRuleConfig { + args: None, + match_config: Some(InterceptRuleMatchConfig { + argv: Some(ArgvMatcherConfig { + exact: Some(vec!["status".to_string()]), + prefix: None, + contains: None, + }), + env: BTreeMap::new(), + }), + action: InterceptActionConfig::Respond { + stdout: "argv match".to_string(), + }, + sandbox: None, + }, + InterceptRuleConfig { + args: Some(vec!["push".to_string()]), + match_config: None, + action: InterceptActionConfig::Approve { timeout_secs: None }, + sandbox: None, + }, + ], + ..CommandPolicyConfig::default() + }; + let argv = vec![b"git".to_vec(), b"push".to_vec(), vec![0xff]]; + + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); + + assert_eq!(resolved.rule_label(), "push"); + assert!(matches!( + resolved.action, + InterceptActionConfig::Approve { .. } + )); + } + #[test] fn resolve_intercept_action_labels_catch_all_rule() { let config = CommandPolicyConfig { intercept: vec![InterceptRuleConfig { - args: Vec::new(), + args: Some(Vec::new()), + match_config: None, action: InterceptActionConfig::Approve { timeout_secs: None }, sandbox: None, }], @@ -660,7 +1114,8 @@ mod intercept_tests { }; let argv = vec![b"git".to_vec(), b"status".to_vec()]; - let resolved = resolve_intercept_action(&config, &argv); + let resolved = resolve_intercept_action(&config, &argv, empty_intercept_env) + .expect("resolve intercept"); assert_eq!(resolved.rule_label(), ""); assert!(matches!( @@ -678,12 +1133,14 @@ mod intercept_tests { let config = CommandPolicyConfig { intercept: vec![ InterceptRuleConfig { - args: vec!["with-override".to_string()], + args: Some(vec!["with-override".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: Some(override_sandbox.clone()), }, InterceptRuleConfig { - args: vec!["no-override".to_string()], + args: Some(vec!["no-override".to_string()]), + match_config: None, action: InterceptActionConfig::Passthrough, sandbox: None, }, @@ -691,15 +1148,29 @@ mod intercept_tests { ..CommandPolicyConfig::default() }; - let with = resolve_intercept_action(&config, &[b"git".to_vec(), b"with-override".to_vec()]); + let with = resolve_intercept_action( + &config, + &[b"git".to_vec(), b"with-override".to_vec()], + empty_intercept_env, + ) + .expect("resolve intercept"); assert_eq!(with.sandbox, Some(&override_sandbox)); - let without = - resolve_intercept_action(&config, &[b"git".to_vec(), b"no-override".to_vec()]); + let without = resolve_intercept_action( + &config, + &[b"git".to_vec(), b"no-override".to_vec()], + empty_intercept_env, + ) + .expect("resolve intercept"); assert_eq!(without.sandbox, None); // Fallthrough (no matching rule) also has no override. - let fallthrough = resolve_intercept_action(&config, &[b"git".to_vec(), b"other".to_vec()]); + let fallthrough = resolve_intercept_action( + &config, + &[b"git".to_vec(), b"other".to_vec()], + empty_intercept_env, + ) + .expect("resolve intercept"); assert_eq!(fallthrough.sandbox, None); } diff --git a/crates/nono-cli/tests/schema_shape.rs b/crates/nono-cli/tests/schema_shape.rs index e491ec180..e8f10d6f1 100644 --- a/crates/nono-cli/tests/schema_shape.rs +++ b/crates/nono-cli/tests/schema_shape.rs @@ -5,7 +5,7 @@ //! phase 2 restructuring. Any future accidental reintroduction of the //! legacy patch namespace or legacy security subkeys will fail here. -use serde_json::Value; +use serde_json::{Value, json}; use std::collections::BTreeSet; fn load_schema() -> Value { @@ -138,7 +138,8 @@ fn test_schema_command_policies_match_tool_sandbox_guide_shape() { "upstream", ], ); - assert_schema_properties(&schema, "InterceptRuleConfig", &["action", "args"]); + assert_schema_properties(&schema, "InterceptRuleConfig", &["action", "args", "match"]); + assert_schema_properties(&schema, "InterceptRuleMatchConfig", &["argv", "env"]); assert_schema_properties( &schema, "CommandPolicyConfig", @@ -204,6 +205,11 @@ fn test_schema_command_policies_match_tool_sandbox_guide_shape() { "ArgvMatcherConfig", &["contains", "exact", "prefix"], ); + assert_schema_properties( + &schema, + "InterceptArgvMatcherConfig", + &["contains", "exact", "prefix"], + ); assert_schema_properties(&schema, "EnvMatcherConfig", &["equals", "one_of"]); assert_schema_properties( &schema, @@ -254,6 +260,155 @@ fn test_schema_command_policies_match_tool_sandbox_guide_shape() { ); } +#[test] +fn test_schema_validates_intercept_match_rule() { + let schema = load_schema(); + let validator = jsonschema::validator_for(&schema).expect("schema compiles"); + let profile = json!({ + "command_policies": { + "commands": { + "git": { + "sandbox": {}, + "intercept": [{ + "match": { + "argv": { "contains": ["push", "--force"] }, + "env": { + "GIT_SSH_COMMAND": { "equals": "ssh -i /tmp/fake_key" } + } + }, + "action": { "type": "approve" } + }] + } + } + } + }); + + validator + .validate(&profile) + .expect("intercept match rule should validate"); +} + +#[test] +fn test_schema_rejects_intercept_args_and_match_together() { + let schema = load_schema(); + let validator = jsonschema::validator_for(&schema).expect("schema compiles"); + let profile = json!({ + "command_policies": { + "commands": { + "git": { + "sandbox": {}, + "intercept": [{ + "args": ["push"], + "match": { "argv": { "prefix": ["push"] } }, + "action": { "type": "passthrough" } + }] + } + } + } + }); + + assert!( + validator.validate(&profile).is_err(), + "intercept rule cannot define both args and match" + ); +} + +#[test] +fn test_schema_rejects_empty_intercept_argv_matcher() { + let schema = load_schema(); + let validator = jsonschema::validator_for(&schema).expect("schema compiles"); + for argv in [ + json!({ "exact": [] }), + json!({ "prefix": [] }), + json!({ "contains": [] }), + ] { + let profile = json!({ + "command_policies": { + "commands": { + "git": { + "sandbox": {}, + "intercept": [{ + "match": { "argv": argv }, + "action": { "type": "passthrough" } + }] + } + } + } + }); + + assert!( + validator.validate(&profile).is_err(), + "intercept argv matcher arrays cannot be empty" + ); + } +} + +#[test] +fn test_schema_rejects_invalid_intercept_argv_matcher_shape() { + let schema = load_schema(); + let validator = jsonschema::validator_for(&schema).expect("schema compiles"); + for argv in [ + json!({}), + json!({ "exact": ["push"], "prefix": ["push"] }), + json!({ "exact": ["push"], "contains": ["push"] }), + json!({ "prefix": ["push"], "contains": ["push"] }), + ] { + let profile = json!({ + "command_policies": { + "commands": { + "git": { + "sandbox": {}, + "intercept": [{ + "match": { "argv": argv }, + "action": { "type": "passthrough" } + }] + } + } + } + }); + + assert!( + validator.validate(&profile).is_err(), + "intercept argv matcher must define exactly one matcher" + ); + } +} + +#[test] +fn test_schema_allows_empty_invocation_argv_matcher_for_compatibility() { + let schema = load_schema(); + let validator = jsonschema::validator_for(&schema).expect("schema compiles"); + for argv in [ + json!({ "exact": [] }), + json!({ "prefix": [] }), + json!({ "contains": [] }), + ] { + let profile = json!({ + "command_policies": { + "commands": { + "git": { + "sandbox": {}, + "from": { + "session": { + "sandbox": {}, + "invocation_policy": { + "approve": [{ + "argv": argv + }] + } + } + } + } + } + } + }); + + validator + .validate(&profile) + .expect("empty invocation argv matcher remains schema-compatible"); + } +} + #[test] fn test_schema_filesystem_has_deny_and_bypass_protection() { let schema = load_schema(); diff --git a/docs/cli/features/tool-sandbox.mdx b/docs/cli/features/tool-sandbox.mdx index fee4ce511..0a3fbb901 100644 --- a/docs/cli/features/tool-sandbox.mdx +++ b/docs/cli/features/tool-sandbox.mdx @@ -112,7 +112,33 @@ An `intercept` rule may carry an optional `sandbox` (the same object described a ### Intercept Actions -Each `intercept` entry is `{ "args": [...], "action": { "type": ... } }`. Rules are evaluated in order; the first whose `args` appear as a contiguous sequence in the invocation's arguments wins (an empty `args` is a catch-all). This lets subcommand rules match after command-global options, such as `git -c foo=bar push --force`. Broad single-argument rules like `["--force"]` match anywhere in the invocation, not only at the subcommand position. The `action.type` selects how the matched subcommand is handled: +Each `intercept` entry selects an invocation with either legacy `args` or an explicit `match` predicate, then applies an `action`. Rules are evaluated in order; the first match wins. Legacy `args` is a contiguous argument sequence matched within the invocation's arguments (an empty `args` is a catch-all). This lets subcommand rules match after command-global options, such as `git -c foo=bar push --force`. Broad single-argument rules like `["--force"]` match anywhere in the invocation, not only at the subcommand position. + +Use `match` when a rule needs an explicit predicate instead of a literal `args` window. `match.argv` accepts the same matcher shapes as `invocation_policy`: `exact`, `prefix`, or `contains`. `match.env` checks environment variables after Tool Sandbox environment filtering, so profiles can mediate out-of-band behavior such as `GIT_SSH_COMMAND` without matching raw host environment state. + +```jsonc +{ + "match": { + "argv": { "contains": ["push", "--force"] } + }, + "action": { "type": "approve" } +} +``` + +```jsonc +{ + "match": { + "env": { + "GIT_SSH_COMMAND": { "equals": "ssh -i /tmp/fake_key" } + } + }, + "action": { "type": "approve" } +} +``` + +`match` is still a predicate over the argv and env that nono observes. It does not perform command-specific semantic normalization such as `--flag=value` versus `--flag value`, short versus long aliases (`-f` versus `--force`), repeated or duplicate flag folding, flag-order canonicalization, or git-specific parsing. It also does not expand environment references embedded inside argv tokens. If a shell expands `$FOO` before `exec`, nono sees the expanded argv; if a caller passes the literal token `$FOO`, nono matches that literal token. + +The `action.type` selects how the matched subcommand is handled: | `action.type` | Behavior | |---|---|