Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<literal path as sent by the agent>`. 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.

Expand Down Expand Up @@ -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:

Expand Down
11 changes: 6 additions & 5 deletions crates/gommage-core/src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -66,21 +67,21 @@ pub fn evaluate(caps: &[Capability], policy: &Policy) -> EvalResult {
file: "<compiled-in>".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 {
name: rule.name.clone(),
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(),
};
}
Expand All @@ -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(),
}
}
Expand Down
188 changes: 176 additions & 12 deletions crates/gommage-core/src/policy.rs
Original file line number Diff line number Diff line change
@@ -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},
};
Expand Down Expand Up @@ -110,6 +110,7 @@ impl Match {
pub struct Policy {
pub rules: Vec<Rule>,
pub version_hash: String,
pub(crate) path_normalizer: PathNormalizer,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -168,11 +169,12 @@ impl Policy {
source_label: &str,
) -> Result<Self, GommageError> {
let substituted = substitute_env(s, env);
let path_normalizer = PathNormalizer::from_env(env);
let raw_rules: Vec<RawRule> = 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();
Expand All @@ -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<Capability> {
self.path_normalizer.normalize_capabilities(caps)
}
}

#[derive(Debug, Clone, Default)]
pub(crate) struct PathNormalizer {
home: Option<String>,
}

impl PathNormalizer {
fn from_env(env: &HashMap<String, String>) -> Self {
Self {
home: env.get("HOME").and_then(|home| normalize_home_value(home)),
}
}

fn normalize_capabilities(&self, caps: &[Capability]) -> Vec<Capability> {
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<String> {
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<String> {
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)]
Expand Down Expand Up @@ -234,6 +323,7 @@ fn load_policy_files(
) -> Result<Policy, GommageError> {
let mut rules: Vec<Rule> = Vec::new();
let mut version = sha2::Sha256::new();
let path_normalizer = PathNormalizer::from_env(env);
use sha2::Digest as _;

for file in files {
Expand All @@ -255,14 +345,20 @@ fn load_policy_files(
}
let raw_rules: Vec<RawRule> = 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,
)?);
}
}

let version_hash = format!("sha256:{}", hex::encode(version.finalize()));
Ok(Policy {
rules,
version_hash,
path_normalizer,
})
}

Expand Down Expand Up @@ -309,7 +405,12 @@ fn update_layered_policy_hash(
hash.update(b"\0");
}

fn compile_rule(raw: RawRule, file: PathBuf, index: usize) -> Result<Rule, GommageError> {
fn compile_rule(
raw: RawRule,
file: PathBuf,
index: usize,
path_normalizer: &PathNormalizer,
) -> Result<Rule, GommageError> {
// 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() {
Expand All @@ -326,9 +427,9 @@ fn compile_rule(raw: RawRule, file: PathBuf, index: usize) -> Result<Rule, Gomma
}

let r#match = Match {
any_capability: compile_globs(&raw.r#match.any_capability)?,
all_capability: compile_globs(&raw.r#match.all_capability)?,
none_capability: compile_globs(&raw.r#match.none_capability)?,
any_capability: compile_globs(&raw.r#match.any_capability, path_normalizer)?,
all_capability: compile_globs(&raw.r#match.all_capability, path_normalizer)?,
none_capability: compile_globs(&raw.r#match.none_capability, path_normalizer)?,
};

Ok(Rule {
Expand All @@ -342,13 +443,17 @@ fn compile_rule(raw: RawRule, file: PathBuf, index: usize) -> Result<Rule, Gomma
})
}

fn compile_globs(pats: &[String]) -> Result<Vec<GlobMatcher>, GommageError> {
fn compile_globs(
pats: &[String],
path_normalizer: &PathNormalizer,
) -> Result<Vec<GlobMatcher>, 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,
})
})
Expand All @@ -375,7 +480,7 @@ pub fn substitute_env(input: &str, env: &HashMap<String, String>) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::Capability;
use crate::{Capability, Decision, evaluate};

fn env() -> HashMap<String, String> {
let mut e = HashMap::new();
Expand Down Expand Up @@ -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")
);
}
}
Loading
Loading