diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7c120..60d7e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ Versioning: [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) — `time`, and `uuid`; this class of change requires a green determinism suite before merge. +### Fixed + +- Filesystem policy evaluation now normalizes leading home aliases (`~/`, + `$HOME/`, `${HOME}/`) for `fs.read`, `fs.search`, and `fs.write` + capabilities before matching rules. Bash redirects such as + `cat >> ~/.gommage/policy.d/x.yaml` now hit the same harness-integrity rules + as native absolute-path writes to `${HOME}/.gommage/...`. + ### Added - `gommage daemon reload` reloads policy and capability mappers in the running diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 070a831..1292884 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -98,7 +98,12 @@ sandboxing and host-agent config review for that boundary. An agent operating on a repo containing hostile content — a symlinked `README.md` pointing at `/etc/shadow`, a project-local `.gommage/policy.d/` override placed under the repo by an attacker, a file named `../../../etc/passwd` — should not be able to extract capabilities Gommage wouldn't otherwise grant. -Gommage's input specification (see Section 3) treats paths as **opaque strings**: no symlink resolution, no relative-path collapsing, no case-folding. The capability mapper renders `fs.read:`. Globs in policy match on that literal. +Gommage's input specification (see Section 3) treats paths as **opaque strings**: +no symlink resolution, no relative-path collapsing, no case-folding. The only +path rewrite is deterministic and lexical: before policy matching, path-shaped +filesystem capabilities (`fs.read`, `fs.search`, `fs.write`) normalize leading +`~`, `~/`, `$HOME/`, and `${HOME}/` to the same `HOME` value used when loading +policy. Globs in policy match on that capability string. **Implication**: your policy patterns should account for likely variations. For example, `fs.write:${EXPEDITION_ROOT}/**` does NOT match `fs.write:/symlink/to/expedition/root/x.txt` because Gommage does not resolve the symlink — the agent would have to produce the canonical path in its tool call for the allow to apply. This is deliberate: the decision boundary is the _string the agent emits_, not the filesystem state. @@ -202,8 +207,9 @@ What the evaluator **does not** read, by deliberate omission: What the mapper does with paths: -- Paths in `tool_input` are passed through as **opaque UTF-8 strings** — no normalization, canonicalization, or symlink resolution. -- Path globs in policy patterns (`fs.write:**/node_modules/**`) match the **string** the agent emitted, not a resolved filesystem path. +- Paths in `tool_input` are passed through as **opaque UTF-8 strings** — no `realpath`, relative-segment collapse, case-folding, Unicode normalization, or symlink resolution. +- Before policy matching, path-shaped filesystem capabilities (`fs.read`, `fs.search`, `fs.write`) normalize only leading home aliases (`~`, `~/`, `$HOME/`, `${HOME}/`) to the `HOME` value supplied at policy load. Relative paths stay relative. +- Path globs in policy patterns (`fs.write:**/node_modules/**`) match that deterministic capability string, not a resolved filesystem path. What is considered a "heuristic" and therefore **NOT** in Gommage: diff --git a/crates/gommage-core/src/evaluator.rs b/crates/gommage-core/src/evaluator.rs index bef5556..8a67d76 100644 --- a/crates/gommage-core/src/evaluator.rs +++ b/crates/gommage-core/src/evaluator.rs @@ -52,7 +52,8 @@ pub struct EvalResult { /// accepts the capabilities wins. /// 3. If no rule matches, fail closed: `Gommage { reason: "no rule matched (fail-closed)" }`. pub fn evaluate(caps: &[Capability], policy: &Policy) -> EvalResult { - if let Some(hit) = hardstop::check(caps) { + let caps = policy.normalize_capabilities(caps); + if let Some(hit) = hardstop::check(&caps) { return EvalResult { decision: Decision::Gommage { reason: format!( @@ -66,13 +67,13 @@ pub fn evaluate(caps: &[Capability], policy: &Policy) -> EvalResult { file: "".to_string(), index: 0, }), - capabilities: caps.to_vec(), + capabilities: caps, policy_version: policy.version_hash.clone(), }; } for rule in &policy.rules { - if rule.r#match.matches(caps) { + if rule.r#match.matches(&caps) { return EvalResult { decision: decision_from_rule(rule), matched_rule: Some(MatchedRule { @@ -80,7 +81,7 @@ pub fn evaluate(caps: &[Capability], policy: &Policy) -> EvalResult { file: rule.source.file.to_string_lossy().to_string(), index: rule.source.index, }), - capabilities: caps.to_vec(), + capabilities: caps, policy_version: policy.version_hash.clone(), }; } @@ -92,7 +93,7 @@ pub fn evaluate(caps: &[Capability], policy: &Policy) -> EvalResult { hard_stop: false, }, matched_rule: None, - capabilities: caps.to_vec(), + capabilities: caps, policy_version: policy.version_hash.clone(), } } diff --git a/crates/gommage-core/src/policy.rs b/crates/gommage-core/src/policy.rs index f9de6f1..1e1d35d 100644 --- a/crates/gommage-core/src/policy.rs +++ b/crates/gommage-core/src/policy.rs @@ -1,8 +1,8 @@ -use crate::error::GommageError; +use crate::{Capability, error::GommageError}; use globset::{Glob, GlobMatcher}; use serde::{Deserialize, Serialize}; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}, }; @@ -110,6 +110,7 @@ impl Match { pub struct Policy { pub rules: Vec, pub version_hash: String, + pub(crate) path_normalizer: PathNormalizer, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -168,11 +169,12 @@ impl Policy { source_label: &str, ) -> Result { let substituted = substitute_env(s, env); + let path_normalizer = PathNormalizer::from_env(env); let raw_rules: Vec = serde_yaml::from_str(&substituted)?; let path = PathBuf::from(source_label); let mut rules = Vec::new(); for (index, raw) in raw_rules.into_iter().enumerate() { - rules.push(compile_rule(raw, path.clone(), index)?); + rules.push(compile_rule(raw, path.clone(), index, &path_normalizer)?); } use sha2::Digest as _; let mut h = sha2::Sha256::new(); @@ -183,8 +185,95 @@ impl Policy { Ok(Policy { rules, version_hash: format!("sha256:{}", hex::encode(h.finalize())), + path_normalizer, }) } + + /// Return the canonical capability form policy evaluation should use. + /// + /// The mapper deliberately stays pure and preserves the tool-call string it + /// saw. Policy loading, however, already has the `${HOME}` substitution + /// environment, so this is the single place where home aliases can be + /// compared safely: `~/x`, `$HOME/x`, `${HOME}/x`, and `/abs/home/x` + /// become the same filesystem capability while relative paths stay + /// relative. + pub fn normalize_capabilities(&self, caps: &[Capability]) -> Vec { + self.path_normalizer.normalize_capabilities(caps) + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PathNormalizer { + home: Option, +} + +impl PathNormalizer { + fn from_env(env: &HashMap) -> Self { + Self { + home: env.get("HOME").and_then(|home| normalize_home_value(home)), + } + } + + fn normalize_capabilities(&self, caps: &[Capability]) -> Vec { + let mut out = Vec::with_capacity(caps.len()); + let mut seen = HashSet::new(); + for cap in caps { + let normalized = self.normalize_capability_str(cap.as_str()); + if seen.insert(normalized.clone()) { + out.push(Capability::new(normalized)); + } + } + out + } + + fn normalize_capability_str(&self, capability: &str) -> String { + let Some((namespace, payload)) = capability.split_once(':') else { + return capability.to_string(); + }; + if !is_path_capability_namespace(namespace) { + return capability.to_string(); + } + let Some(path) = self.normalize_home_path(payload) else { + return capability.to_string(); + }; + format!("{namespace}:{path}") + } + + fn normalize_home_path(&self, path: &str) -> Option { + let home = self.home.as_deref()?; + if path == "~" || path == "$HOME" || path == "${HOME}" { + return Some(home.to_string()); + } + for prefix in ["~/", "$HOME/", "${HOME}/"] { + if let Some(rest) = path.strip_prefix(prefix) { + return Some(join_home(home, rest)); + } + } + None + } +} + +fn normalize_home_value(home: &str) -> Option { + if home.is_empty() { + return None; + } + let trimmed = home.trim_end_matches('/'); + if trimmed.is_empty() { + return Some("/".to_string()); + } + Some(trimmed.to_string()) +} + +fn join_home(home: &str, rest: &str) -> String { + if home == "/" { + format!("/{rest}") + } else { + format!("{home}/{rest}") + } +} + +fn is_path_capability_namespace(namespace: &str) -> bool { + matches!(namespace, "fs.read" | "fs.search" | "fs.write") } #[derive(Debug)] @@ -234,6 +323,7 @@ fn load_policy_files( ) -> Result { let mut rules: Vec = Vec::new(); let mut version = sha2::Sha256::new(); + let path_normalizer = PathNormalizer::from_env(env); use sha2::Digest as _; for file in files { @@ -255,7 +345,12 @@ fn load_policy_files( } let raw_rules: Vec = serde_yaml::from_str(&substituted)?; for (index, raw) in raw_rules.into_iter().enumerate() { - rules.push(compile_rule(raw, file.path.clone(), index)?); + rules.push(compile_rule( + raw, + file.path.clone(), + index, + &path_normalizer, + )?); } } @@ -263,6 +358,7 @@ fn load_policy_files( Ok(Policy { rules, version_hash, + path_normalizer, }) } @@ -309,7 +405,12 @@ fn update_layered_policy_hash( hash.update(b"\0"); } -fn compile_rule(raw: RawRule, file: PathBuf, index: usize) -> Result { +fn compile_rule( + raw: RawRule, + file: PathBuf, + index: usize, + path_normalizer: &PathNormalizer, +) -> Result { // Validate decision/field combinations early — a policy with inconsistent // fields should fail at load, not at evaluation. if raw.decision == RuleDecision::AskPicto && raw.required_scope.is_none() { @@ -326,9 +427,9 @@ fn compile_rule(raw: RawRule, file: PathBuf, index: usize) -> Result Result Result, GommageError> { +fn compile_globs( + pats: &[String], + path_normalizer: &PathNormalizer, +) -> Result, GommageError> { pats.iter() .map(|p| { - Glob::new(p) + let normalized = path_normalizer.normalize_capability_str(p); + Glob::new(&normalized) .map(|g| g.compile_matcher()) .map_err(|e| GommageError::Glob { - pattern: p.clone(), + pattern: normalized, source: e, }) }) @@ -375,7 +480,7 @@ pub fn substitute_env(input: &str, env: &HashMap) -> String { #[cfg(test)] mod tests { use super::*; - use crate::Capability; + use crate::{Capability, Decision, evaluate}; fn env() -> HashMap { let mut e = HashMap::new(); @@ -570,4 +675,63 @@ mod tests { let err = Policy::from_yaml_string(yaml, &HashMap::new(), "t").unwrap_err(); assert!(matches!(err, GommageError::Policy(_))); } + + #[test] + fn home_alias_capability_hits_home_rule_before_broad_allow() { + let yaml = r#" +- name: deny-gommage-home-tamper + decision: gommage + match: + any_capability: ["fs.write:${HOME}/.gommage/policy.d/**"] + reason: "protected" +- name: broad-home-allow + decision: allow + match: + any_capability: ["fs.write:~/.gommage/**"] +"#; + let mut env = HashMap::new(); + env.insert("HOME".to_string(), "/home/operator".to_string()); + let policy = Policy::from_yaml_string(yaml, &env, "test.yaml").unwrap(); + + let eval = evaluate( + &[Capability::new("fs.write:~/.gommage/policy.d/x.yaml")], + &policy, + ); + + assert_eq!( + eval.matched_rule.as_ref().map(|rule| rule.name.as_str()), + Some("deny-gommage-home-tamper") + ); + assert!(matches!(eval.decision, Decision::Gommage { .. })); + assert_eq!( + eval.capabilities, + vec![Capability::new( + "fs.write:/home/operator/.gommage/policy.d/x.yaml" + )] + ); + } + + #[test] + fn tilde_policy_pattern_matches_absolute_capability() { + let yaml = r#" +- name: deny-shell-rc-write + decision: gommage + match: + any_capability: ["fs.write:~/.zshrc"] + reason: "protected" +"#; + let mut env = HashMap::new(); + env.insert("HOME".to_string(), "/home/operator".to_string()); + let policy = Policy::from_yaml_string(yaml, &env, "test.yaml").unwrap(); + + let eval = evaluate( + &[Capability::new("fs.write:/home/operator/.zshrc")], + &policy, + ); + + assert_eq!( + eval.matched_rule.as_ref().map(|rule| rule.name.as_str()), + Some("deny-shell-rc-write") + ); + } } diff --git a/crates/gommage-core/tests/determinism.rs b/crates/gommage-core/tests/determinism.rs index cef731e..cfffae9 100644 --- a/crates/gommage-core/tests/determinism.rs +++ b/crates/gommage-core/tests/determinism.rs @@ -32,13 +32,20 @@ struct Fixture { #[derive(Debug, Deserialize, Clone)] #[serde(tag = "kind", rename_all = "snake_case")] enum Expected { - Allow, + Allow { + #[serde(default)] + matched_rule: Option, + }, Gommage { #[serde(default)] hard_stop: Option, + #[serde(default)] + matched_rule: Option, }, AskPicto { required_scope: String, + #[serde(default)] + matched_rule: Option, }, } @@ -106,10 +113,13 @@ fn run_fixture(fixture: &Fixture, mapper: &CapabilityMapper) -> EvalResult { fn assert_matches_expected(path: &Path, fx: &Fixture, eval: &EvalResult) { match (&fx.expected, &eval.decision) { - (Expected::Allow, Decision::Allow) => {} + (Expected::Allow { matched_rule }, Decision::Allow) => { + assert_expected_rule(path, fx, eval, matched_rule.as_deref()); + } ( Expected::Gommage { hard_stop: expected_hs, + matched_rule, }, Decision::Gommage { hard_stop, .. }, ) => { @@ -122,10 +132,12 @@ fn assert_matches_expected(path: &Path, fx: &Fixture, eval: &EvalResult) { path.display() ); } + assert_expected_rule(path, fx, eval, matched_rule.as_deref()); } ( Expected::AskPicto { required_scope: expected_scope, + matched_rule, }, Decision::AskPicto { required_scope, .. }, ) => { @@ -136,6 +148,7 @@ fn assert_matches_expected(path: &Path, fx: &Fixture, eval: &EvalResult) { fx.name, path.display() ); + assert_expected_rule(path, fx, eval, matched_rule.as_deref()); } (exp, got) => panic!( "fixture {:?} at {}:\n expected: {exp:?}\n got: {got:?}\n caps: {:?}", @@ -146,6 +159,21 @@ fn assert_matches_expected(path: &Path, fx: &Fixture, eval: &EvalResult) { } } +fn assert_expected_rule(path: &Path, fx: &Fixture, eval: &EvalResult, expected: Option<&str>) { + let Some(expected) = expected else { + return; + }; + let actual = eval.matched_rule.as_ref().map(|rule| rule.name.as_str()); + assert_eq!( + Some(expected), + actual, + "fixture {:?} at {}: matched rule mismatch\n expected: {expected:?}\n got: {actual:?}\n caps: {:?}", + fx.name, + path.display(), + eval.capabilities + ); +} + #[test] fn fixtures_match_expected_decisions() { let fixtures = load_fixtures(); diff --git a/crates/gommage-core/tests/proptest_robustness.rs b/crates/gommage-core/tests/proptest_robustness.rs index 2aa39bc..2db5926 100644 --- a/crates/gommage-core/tests/proptest_robustness.rs +++ b/crates/gommage-core/tests/proptest_robustness.rs @@ -23,6 +23,10 @@ use gommage_core::{ use proptest::prelude::*; use std::collections::HashMap; +fn repo_root() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + // ---------------------------------------------------------------------------- // 1. Capability mapper fuzz // ---------------------------------------------------------------------------- @@ -50,8 +54,7 @@ fn arb_tool_call() -> impl Strategy { } fn shipped_mapper() -> CapabilityMapper { - let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - CapabilityMapper::load_from_dir(&repo_root.join("capabilities")) + CapabilityMapper::load_from_dir(&repo_root().join("capabilities")) .expect("loading shipped mapper") } @@ -68,6 +71,94 @@ proptest! { } } +// ---------------------------------------------------------------------------- +// 1b. Home path spelling equivalence +// ---------------------------------------------------------------------------- + +fn shipped_policy(home: &str) -> Policy { + let mut env = HashMap::::new(); + env.insert("HOME".to_string(), home.to_string()); + env.insert( + "EXPEDITION_ROOT".to_string(), + "/__no_expedition__".to_string(), + ); + Policy::load_from_dir(&repo_root().join("policies"), &env).expect("loading shipped policy") +} + +fn bash_redirect_to(path: &str) -> ToolCall { + ToolCall { + tool: "Bash".to_string(), + input: serde_json::json!({ "command": format!("printf x > {path}") }), + } +} + +fn decision_summary(eval: &gommage_core::EvalResult) -> (Decision, Option) { + ( + eval.decision.clone(), + eval.matched_rule.as_ref().map(|rule| rule.name.clone()), + ) +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + ..ProptestConfig::default() + })] + + #[test] + fn home_path_spellings_decide_identically( + suffix in prop::sample::select(vec![ + "/.gommage/policy.d/x.yaml", + "/.gommage/capabilities.d/x.yaml", + "/.gommage/key.ed25519", + "/.claude/settings.json", + "/.claude/hooks/pretool.sh", + "/.codex/hooks.json", + "/.codex/hooks/pretool.sh", + "/.local/bin/gommage", + "/.local/bin/gommage-daemon", + "/.local/bin/gommage-mcp", + "/.zshrc", + "/notes.txt", + ]) + ) { + let home = "/__home__"; + let policy = shipped_policy(home); + let mapper = shipped_mapper(); + let absolute = format!("{home}{suffix}"); + let expected = { + let caps = mapper.map(&bash_redirect_to(&absolute)); + let eval = evaluate(&caps, &policy); + decision_summary(&eval) + }; + + for spelling in [ + absolute.clone(), + format!("~{suffix}"), + format!("$HOME{suffix}"), + format!("${{HOME}}{suffix}"), + ] { + let caps = mapper.map(&bash_redirect_to(&spelling)); + let eval = evaluate(&caps, &policy); + prop_assert_eq!( + decision_summary(&eval), + expected.clone(), + "home spelling {:?} diverged from absolute {:?}; caps: {:?}", + spelling, + absolute, + eval.capabilities + ); + prop_assert!( + eval.capabilities + .iter() + .any(|cap| cap.as_str() == format!("fs.write:{absolute}")), + "evaluation did not retain the canonical fs.write capability for {spelling:?}; caps: {:?}", + eval.capabilities + ); + } + } +} + // ---------------------------------------------------------------------------- // 2. Policy YAML parser fuzz // ---------------------------------------------------------------------------- diff --git a/crates/gommage-stdlib/policies/10-filesystem.yaml b/crates/gommage-stdlib/policies/10-filesystem.yaml index 302e130..c693e56 100644 --- a/crates/gommage-stdlib/policies/10-filesystem.yaml +++ b/crates/gommage-stdlib/policies/10-filesystem.yaml @@ -54,22 +54,15 @@ # --- Shell-rc persistence: deny writes to login/shell rc files -------------- # A write to a login/shell rc file is how an agent makes code run on every new # shell. R2 already emits fs.write from tee/cp/redirect, so `echo x >> ~/.zshrc` -# and `tee ~/.bashrc` reach this deny. Both the literal-tilde form (the mapper -# captures `~/.zshrc` verbatim — the shell never expands it in the string) and -# the ${HOME}-prefixed and absolute /etc forms are covered. A write to a normal -# project file under EXPEDITION_ROOT is unaffected. Placed before the allow -# rules so it wins even if an rc path sits under the project root. +# and `tee ~/.bashrc` reach this deny after the policy evaluator normalizes +# leading `~/`, `$HOME/`, and `${HOME}/` aliases to the same HOME prefix used +# by these patterns. A write to a normal project file under EXPEDITION_ROOT is +# unaffected. Placed before the allow rules so it wins even if an rc path sits +# under the project root. - name: deny-shell-rc-write decision: gommage match: any_capability: - - "fs.write:~/.bashrc" - - "fs.write:~/.zshrc" - - "fs.write:~/.bash_profile" - - "fs.write:~/.zshenv" - - "fs.write:~/.profile" - - "fs.write:~/.zprofile" - - "fs.write:~/.config/fish/config.fish" - "fs.write:${HOME}/.bashrc" - "fs.write:${HOME}/.zshrc" - "fs.write:${HOME}/.bash_profile" diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 54040e1..0870b73 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -64,6 +64,12 @@ and escapes), strips leading wrappers from each segment (`VAR=value`, `env`, `$(...)` / backtick command substitutions, and surfaces genuine output-redirect targets. Each resulting candidate is run through the stdlib capability rules. +Before policy matching, path-shaped filesystem capabilities (`fs.read`, +`fs.search`, `fs.write`) normalize leading `~`, `~/`, `$HOME/`, and `${HOME}/` +to the `HOME` value used when loading policy. That is a lexical alias rewrite, +not `realpath`: relative paths, `..`, symlinks, `~user`, and other variables are +left untouched. + The effect: a policy gate cannot be evaded by command **shape**. These all surface `fs.read:/etc/shadow` and are gated like a `Read`: diff --git a/docs/input-schema.md b/docs/input-schema.md index 88379e2..f129712 100644 --- a/docs/input-schema.md +++ b/docs/input-schema.md @@ -53,10 +53,29 @@ Gommage treats every path it sees in `input.*` fields as an **opaque UTF-8 strin - Apply Unicode normalisation (NFC / NFD / NFKC / NFKD). - Decode percent-encoded bytes. -A policy pattern like `fs.write:${EXPEDITION_ROOT}/**` matches the **literal string** in the capability. If the agent says `file_path = "/Users/you/proj/src/x.rs"`, the capability is `fs.write:/Users/you/proj/src/x.rs` and the glob is matched against that string. If the hook payload instead says `file_path = "src/x.rs"` with `cwd = "/Users/you/proj"`, the adapter adds `__gommage_file_path = "/Users/you/proj/src/x.rs"` so the stdlib emits both the raw and resolved forms. +There is one deterministic lexical alias rule after mapping and before policy +matching: for path-shaped filesystem capabilities (`fs.read`, `fs.search`, +`fs.write`), leading `~`, `~/`, `$HOME/`, and `${HOME}/` are rewritten to the +same `HOME` value that was supplied to policy loading. This does not touch +`~user`, other environment variables, relative paths, symlinks, or `..` +segments. It only makes shell-spelled home paths and native absolute home paths +reach the same rule. + +A policy pattern like `fs.write:${EXPEDITION_ROOT}/**` matches the **capability +string after this lexical home-alias step**. If the agent says +`file_path = "/Users/you/proj/src/x.rs"`, the capability is +`fs.write:/Users/you/proj/src/x.rs` and the glob is matched against that string. +If the hook payload instead says `file_path = "src/x.rs"` with +`cwd = "/Users/you/proj"`, the adapter adds +`__gommage_file_path = "/Users/you/proj/src/x.rs"` so the stdlib emits both the +raw and resolved forms. **Why no normalisation?** Every normalisation is a small inference step that depends on filesystem state at decision time. Resolving a symlink today is a different decision than resolving it tomorrow. Gommage's contract is that the decision is a pure function of the input — so the input must carry whatever semantics the agent wants honoured. Agents that want canonicalised behaviour should canonicalise in their tool-call construction (`realpath`, Node `fs.realpath`, etc.) before emitting. +The home-alias rewrite above is not filesystem normalisation: it reads no +filesystem state, uses the policy load environment already needed for `${HOME}` +patterns, and preserves relative-path hard-stop semantics. + **Implication for policy authors**: for real hook traffic, prefer the resolved stdlib capabilities (`fs.write:/absolute/path` and, when available, `fs.write.git_branch::/absolute/path`) for project-scoped gates. For raw daemon `ToolCall` JSON that did not pass through the hook adapter, your patterns still need to account for the literal paths the caller supplied, or rely on the fail-closed default to deny the rest. --- diff --git a/policies/10-filesystem.yaml b/policies/10-filesystem.yaml index 302e130..c693e56 100644 --- a/policies/10-filesystem.yaml +++ b/policies/10-filesystem.yaml @@ -54,22 +54,15 @@ # --- Shell-rc persistence: deny writes to login/shell rc files -------------- # A write to a login/shell rc file is how an agent makes code run on every new # shell. R2 already emits fs.write from tee/cp/redirect, so `echo x >> ~/.zshrc` -# and `tee ~/.bashrc` reach this deny. Both the literal-tilde form (the mapper -# captures `~/.zshrc` verbatim — the shell never expands it in the string) and -# the ${HOME}-prefixed and absolute /etc forms are covered. A write to a normal -# project file under EXPEDITION_ROOT is unaffected. Placed before the allow -# rules so it wins even if an rc path sits under the project root. +# and `tee ~/.bashrc` reach this deny after the policy evaluator normalizes +# leading `~/`, `$HOME/`, and `${HOME}/` aliases to the same HOME prefix used +# by these patterns. A write to a normal project file under EXPEDITION_ROOT is +# unaffected. Placed before the allow rules so it wins even if an rc path sits +# under the project root. - name: deny-shell-rc-write decision: gommage match: any_capability: - - "fs.write:~/.bashrc" - - "fs.write:~/.zshrc" - - "fs.write:~/.bash_profile" - - "fs.write:~/.zshenv" - - "fs.write:~/.profile" - - "fs.write:~/.zprofile" - - "fs.write:~/.config/fish/config.fish" - "fs.write:${HOME}/.bashrc" - "fs.write:${HOME}/.zshrc" - "fs.write:${HOME}/.bash_profile" diff --git a/tests/determinism/fixtures/harness_claude_hooks_tilde_denied.json b/tests/determinism/fixtures/harness_claude_hooks_tilde_denied.json new file mode 100644 index 0000000..e01aa8e --- /dev/null +++ b/tests/determinism/fixtures/harness_claude_hooks_tilde_denied.json @@ -0,0 +1,6 @@ +{ + "name": "harness-claude-hooks-tilde-denied", + "note": "A literal-tilde Bash redirect under ~/.claude/hooks must hit the harness integrity rule, not fall through to a broader allow.", + "call": { "tool": "Bash", "input": { "command": "printf '#!/bin/sh\n' > ~/.claude/hooks/pretool.sh" } }, + "expected": { "kind": "gommage", "hard_stop": false, "matched_rule": "deny-agent-hook-config-tamper" } +} diff --git a/tests/determinism/fixtures/harness_claude_settings_tilde_denied.json b/tests/determinism/fixtures/harness_claude_settings_tilde_denied.json new file mode 100644 index 0000000..a077cea --- /dev/null +++ b/tests/determinism/fixtures/harness_claude_settings_tilde_denied.json @@ -0,0 +1,6 @@ +{ + "name": "harness-claude-settings-tilde-denied", + "note": "A literal-tilde Bash redirect to Claude settings is the same harness config write as the absolute HOME form.", + "call": { "tool": "Bash", "input": { "command": "printf '{}' > ~/.claude/settings.json" } }, + "expected": { "kind": "gommage", "hard_stop": false, "matched_rule": "deny-agent-hook-config-tamper" } +} diff --git a/tests/determinism/fixtures/harness_gommage_binary_tilde_denied.json b/tests/determinism/fixtures/harness_gommage_binary_tilde_denied.json new file mode 100644 index 0000000..982822a --- /dev/null +++ b/tests/determinism/fixtures/harness_gommage_binary_tilde_denied.json @@ -0,0 +1,6 @@ +{ + "name": "harness-gommage-binary-tilde-denied", + "note": "A literal-tilde Bash redirect to the primary gommage binary must hit deny-gommage-binary-tamper.", + "call": { "tool": "Bash", "input": { "command": "cat > ~/.local/bin/gommage << 'EOF'\n#!/bin/sh\nEOF" } }, + "expected": { "kind": "gommage", "hard_stop": false, "matched_rule": "deny-gommage-binary-tamper" } +} diff --git a/tests/determinism/fixtures/harness_gommage_daemon_binary_tilde_denied.json b/tests/determinism/fixtures/harness_gommage_daemon_binary_tilde_denied.json new file mode 100644 index 0000000..43c0595 --- /dev/null +++ b/tests/determinism/fixtures/harness_gommage_daemon_binary_tilde_denied.json @@ -0,0 +1,6 @@ +{ + "name": "harness-gommage-daemon-binary-tilde-denied", + "note": "A literal-tilde Bash redirect to gommage-daemon must hit deny-gommage-binary-tamper.", + "call": { "tool": "Bash", "input": { "command": "cat > ~/.local/bin/gommage-daemon << 'EOF'\n#!/bin/sh\nEOF" } }, + "expected": { "kind": "gommage", "hard_stop": false, "matched_rule": "deny-gommage-binary-tamper" } +} diff --git a/tests/determinism/fixtures/harness_gommage_mcp_binary_tilde_denied.json b/tests/determinism/fixtures/harness_gommage_mcp_binary_tilde_denied.json new file mode 100644 index 0000000..618a4ab --- /dev/null +++ b/tests/determinism/fixtures/harness_gommage_mcp_binary_tilde_denied.json @@ -0,0 +1,6 @@ +{ + "name": "harness-gommage-mcp-binary-tilde-denied", + "note": "A literal-tilde Bash redirect to gommage-mcp must hit deny-gommage-binary-tamper.", + "call": { "tool": "Bash", "input": { "command": "cat > ~/.local/bin/gommage-mcp << 'EOF'\n#!/bin/sh\nEOF" } }, + "expected": { "kind": "gommage", "hard_stop": false, "matched_rule": "deny-gommage-binary-tamper" } +} diff --git a/tests/determinism/fixtures/harness_gommage_policy_write_tilde_denied.json b/tests/determinism/fixtures/harness_gommage_policy_write_tilde_denied.json new file mode 100644 index 0000000..4e4d91c --- /dev/null +++ b/tests/determinism/fixtures/harness_gommage_policy_write_tilde_denied.json @@ -0,0 +1,6 @@ +{ + "name": "harness-gommage-policy-write-tilde-denied", + "note": "Regression for the production bypass: Bash redirects surface the target as a literal tilde path, but policy.d still must hit deny-gommage-home-tamper.", + "call": { "tool": "Bash", "input": { "command": "cat >> ~/.gommage/policy.d/x.yaml << 'EOF'\n- name: allow-all\n decision: allow\nEOF" } }, + "expected": { "kind": "gommage", "hard_stop": false, "matched_rule": "deny-gommage-home-tamper" } +} diff --git a/tests/determinism/fixtures/shellrc_bashrc_tee_deny.json b/tests/determinism/fixtures/shellrc_bashrc_tee_deny.json index 7493697..bff057e 100644 --- a/tests/determinism/fixtures/shellrc_bashrc_tee_deny.json +++ b/tests/determinism/fixtures/shellrc_bashrc_tee_deny.json @@ -1,6 +1,6 @@ { "name": "tee-to-bashrc-is-denied", - "note": "tee emits fs.write:~/.bashrc (R2), which deny-shell-rc-write denies. Closes the persistence channel where an agent writes its own startup hook.", + "note": "tee emits a literal-tilde fs.write target; policy evaluation normalizes it to HOME before deny-shell-rc-write matches. Closes the persistence channel where an agent writes its own startup hook.", "call": { "tool": "Bash", "input": { "command": "echo payload | tee ~/.bashrc" } }, "expected": { "kind": "gommage" } } diff --git a/tests/determinism/fixtures/shellrc_zshrc_append_deny.json b/tests/determinism/fixtures/shellrc_zshrc_append_deny.json index b0843d8..c244657 100644 --- a/tests/determinism/fixtures/shellrc_zshrc_append_deny.json +++ b/tests/determinism/fixtures/shellrc_zshrc_append_deny.json @@ -1,6 +1,6 @@ { "name": "append-to-zshrc-is-denied", - "note": "R2 emits fs.write:~/.zshrc from the redirect, and deny-shell-rc-write in 10-filesystem denies it: a login/shell rc runs code on every new shell. The mapper captures `~/.zshrc` verbatim (the shell never expands the tilde in the string), and the deny rule lists both the literal-tilde and ${HOME} forms.", + "note": "R2 emits a literal-tilde fs.write target from the redirect; policy evaluation normalizes it to HOME before deny-shell-rc-write matches. A login/shell rc runs code on every new shell.", "call": { "tool": "Bash", "input": { "command": "echo export PROMPT_HOOK=1 >> ~/.zshrc" } }, "expected": { "kind": "gommage" } }