From 304dd2bac6802891ac21ef664165cc80ec727f92 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sun, 8 Nov 2026 05:02:36 +0000 Subject: [PATCH 1/4] Guard web and shell tool arguments against email disclosure --- Cargo.lock | 1 + Cargo.toml | 2 + README.md | 9 ++ docs/REFERENCE.md | 59 ++++++++++- plugins/claude-code/hooks/hooks.json | 6 ++ src/hook.rs | 33 ++++++ src/hook/privacy.rs | 104 +++++++++++++++++++ src/main.rs | 23 +++-- tests/claude_plugin.rs | 21 +++- tests/hook_cli.rs | 147 +++++++++++++++++++++++++++ 10 files changed, 395 insertions(+), 10 deletions(-) create mode 100644 src/hook/privacy.rs diff --git a/Cargo.lock b/Cargo.lock index acb537b..dcaa73d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1344,6 +1344,7 @@ dependencies = [ "ignore", "jsonschema", "linkify", + "percent-encoding", "pulldown-cmark", "quick-junit", "regex", diff --git a/Cargo.toml b/Cargo.toml index f9e8fc9..f93f89f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,8 @@ ignore = "0.4" # The engine under `grep-regex`, used directly where a pattern is matched # against a string already in memory rather than searched through a file. regex = "1" +# Decode URL-encoded tool arguments before the privacy hook examines them. +percent-encoding = "2" # Where a URL written out in prose ends and the sentence's punctuation begins, # for `citations-linked`. A URL pattern of our own gets that boundary wrong on # a trailing period, a closing parenthesis and a quote; this crate's tests are diff --git a/README.md b/README.md index 22a3af2..6c90f0c 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,15 @@ second matcher in this binary. An unknown harness name is refused rather than guessed at, because where to find the text in its event cannot be derived from its name. +The Claude Code plugin also runs `uphold hook claude-code --privacy` before +`Bash`, `WebFetch` and `WebSearch`. It refuses email-shaped text in arguments, +including headers and URL-encoded strings, without logging the address or +reading account credentials. Shell checks are conservative: a local command +containing an address is refused too. This checks visible arguments only; +headers inserted later by the client, scripts, environment variables and +hosted tools that do not invoke the hook need protection at their own boundary. +See [the privacy hook contract](docs/REFERENCE.md#privacy-before-web-and-shell-tools). + For Claude Code, enable the `uphold` plugin this repository ships, at the tag of the release you installed: diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4f6d198..9565325 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -3244,12 +3244,12 @@ the folder needs no install of their own: } ``` -The plugin's `hooks/hooks.json` holds three entries, each running -`uphold hook claude-code`: +The plugin's `hooks/hooks.json` holds the publication and privacy checks: | event | matcher | options | |---|---|---| | `PreToolUse` | `mcp__github__.*` | `onFailure: "block"` | +| `PreToolUse` | `^(Bash\|WebFetch\|WebSearch)$` | `--privacy`, `onFailure: "block"` | | `SessionStart` | | `async: true` | | `Stop` | | `timeout: 900`, `onFailure: "block"` | @@ -3262,6 +3262,61 @@ remove it. A cloud session does not add marketplaces from a repository's `extraKnownMarketplaces`, so the plugin does not reach one through project settings. +### Privacy before web and shell tools + +`uphold hook claude-code --privacy` reads the same pre-call JSON and returns +the same denial document as the publication hook. The shipped Claude Code +plugin installs it for `Bash`, `WebFetch` and `WebSearch`. An inline installation +adds this group to `hooks.PreToolUse`: + +```json +{ + "matcher": "^(Bash|WebFetch|WebSearch)$", + "hooks": [{"type": "command", "command": "uphold hook claude-code --privacy", "onFailure": "block"}] +} +``` + +Privacy mode checks every string value and object key in `tool_input`, including +nested headers, URL parameters, bodies and search queries. It refuses text shaped +like an email address: dot-atom characters from RFC 5322 section 3.2.3, quoted +local parts, and Unicode letters and numbers as in SMTPUTF8 (RFC 6531). This is +a conservative detector, not validation of every mailbox syntax. It requires +no private identity list or account credential access and protects addresses +whether they belong to the operator or someone else. + +JSON escapes are decoded by the event reader. URL percent encoding is decoded +through a bounded number of layers; input that still changes after that bound +is refused rather than reported as inspected. Ordinary calls return silently. +A refusal names `no-email-in-tool-arguments` without printing the tool name, +arguments or address. `--junit DIR` records passes and refusals with the same +content withholding. The check runs independently of repository publication +policy and `UPHOLD_ALLOW`; selecting privacy mode always runs it. A malformed +event, absent `tool_input`, unsupported harness or event other than +`PreToolUse` exits `2`. Keep the blocking failure option in the installation. + +Shell commands are checked in full, including local commands: an email address +in `git config`, an SSH login or a mail command is also refused. Make intentional +identity configuration or email delivery outside the agent's guarded shell. +This avoids trying to infer arbitrary shell code's network effects from its +spelling. The publication hook remains a separate entry so privacy checks do +not apply forge-write policy to shell commands or replace existing checks. + +The guarantee is limited to the arguments the harness supplies. This hook +cannot inspect headers injected by an HTTP client after the hook, evaluate +shell concatenation, decode arbitrary encodings, read script or upload-file +contents, inspect environment variables, or observe traffic from tools that +bypass local hooks. It does not detect every kind of personal information or +credential. Protect those paths in the sending client or an enforced outbound +proxy. A passing hook is not an attestation that the eventual network request +contains no personal information. + +`--privacy` also accepts the shared `codex` event protocol. Its shipped plugin +does not install this mode; an operator must attach it to the applicable local +tools. Hosted web tools that do not emit local hook events remain outside its +reach. + +### Installing the Codex plugin + For [Codex](https://developers.openai.com/codex/), this repository publishes `.agents/plugins/marketplace.json`, pointing to `plugins/codex`. Add the marketplace at the same release tag as the installed binary: diff --git a/plugins/claude-code/hooks/hooks.json b/plugins/claude-code/hooks/hooks.json index b47d91d..4cd7b3b 100644 --- a/plugins/claude-code/hooks/hooks.json +++ b/plugins/claude-code/hooks/hooks.json @@ -1,6 +1,12 @@ { "hooks": { "PreToolUse": [ + { + "matcher": "^(Bash|WebFetch|WebSearch)$", + "hooks": [ + {"type": "command", "command": "uphold hook claude-code --privacy", "onFailure": "block"} + ] + }, { "matcher": "mcp__github__.*", "hooks": [ diff --git a/src/hook.rs b/src/hook.rs index 7923c24..3f3ab75 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -53,6 +53,7 @@ //! the end-of-turn gate in [`stop`]; every other event, and an event naming //! none, is the pre-call judgment below, as it was before the gate existed. +mod privacy; mod stop; mod table; @@ -188,6 +189,7 @@ pub(crate) fn run( harness: &str, found: Option<&(PathBuf, PathBuf)>, evidence: Option, + privacy: bool, ) -> Result { let shape = SHAPES .iter() @@ -214,6 +216,12 @@ pub(crate) fn run( )) })?; + if privacy && event.get("hook_event_name").and_then(Value::as_str) != Some("PreToolUse") { + return Err(Fatal::new( + "--privacy requires a PreToolUse event; no tool arguments were checked", + )); + } + if let Some(name @ ("SessionStart" | "Stop")) = event.pointer("/hook_event_name").and_then(Value::as_str) { @@ -271,6 +279,31 @@ pub(crate) fn run( ); let subject = observation.read()?.copied(); + // A separate matcher selects privacy calls. They are checked without + // repository publication policy: a shell command is not a forge write. + if privacy { + let mut record = Record::new( + evidence.map(Place::Directory), + Run::Hook(harness.to_owned()), + ); + record.open()?; + let judgment = match privacy::judge(subject) { + Ok(judgment) => judgment, + Err(error) => return record.finish(Err(error)), + }; + let report = judgment.refused().map(|verdict| match verdict { + Verdict::Guard(refusal) => refusal.report.clone(), + Verdict::Rule(failure) => failure.report(), + }); + record.ledger.push(judgment); + record.write(false)?; + if let Some(report) = report { + eprintln!("{report}"); + println!("{}", refusal_document(shape, &report)?); + } + return Ok(shape.refused); + } + // Everything a piece of published text is judged by, from the one place // that knows what that is. The host-identity rules run wherever the call // was made from, policy or no policy: `text::load_for` carries an empty diff --git a/src/hook/privacy.rs b/src/hook/privacy.rs new file mode 100644 index 0000000..ba4ac00 --- /dev/null +++ b/src/hook/privacy.rs @@ -0,0 +1,104 @@ +//! Email-shaped text in pending arguments, before a web or shell tool runs. +//! +//! This is a conservative disclosure check, not a mailbox validator or a +//! network sandbox. It never reads account credentials and never quotes the +//! subject, even in evidence. Hidden headers and script contents are outside +//! the pre-call event and cannot be certified by inspecting it. + +use regex::Regex; +use serde_json::Value; + +use crate::error::{Fatal, Result}; +use crate::guard::Refusal; +use crate::text::{Answer, Judgment, Verdict}; + +const ID: &str = "no-email-in-tool-arguments"; +const REPORT: &str = "uphold refused: no-email-in-tool-arguments. Email-shaped text was found in the pending tool arguments, or URL encoding exceeded the inspection limit. The address and arguments are withheld. Remove personal information before retrying. No tool call was allowed by this hook."; + +// RFC 5322 section 3.2.3's dot-atom characters, with letters and numbers +// extended for SMTPUTF8 (RFC 6531). Deliberately accepts more than a valid +// mailbox: malformed addresses can still identify a person. This does not +// attempt to reconstruct shell expressions. +const ADDRESS: &str = r#"(?:"[^"\r\n]+"|[\p{L}\p{N}.!#$%&'*+/=?^_`{|}~-]+)@[\p{L}\p{N}](?:[\p{L}\p{N}.-]*[\p{L}\p{N}])?"#; + +fn sensitive(text: &str, address: &Regex) -> bool { + let mut decoded = text.to_owned(); + // A fixed bound avoids quadratic work on adversarial nested encoding. + // If another layer remains after it, refuse rather than clear unread text. + for _ in 0..4 { + if address.is_match(&decoded) { + return true; + } + let next = percent_encoding::percent_decode_str(&decoded) + .decode_utf8_lossy() + .into_owned(); + if next == decoded { + return false; + } + decoded = next; + } + address.is_match(&decoded) + || percent_encoding::percent_decode_str(&decoded).decode_utf8_lossy() != decoded +} + +fn contains_sensitive(subject: &Value, address: &Regex) -> bool { + match subject { + Value::String(text) => sensitive(text, address), + Value::Array(values) => values + .iter() + .any(|value| contains_sensitive(value, address)), + Value::Object(values) => values + .iter() + .any(|(key, value)| sensitive(key, address) || contains_sensitive(value, address)), + _ => false, + } +} + +/// Judge every string and object key without retaining the matched content. +pub(super) fn judge(subject: Option<&Value>) -> Result { + let address = Regex::new(ADDRESS) + .map_err(|_| Fatal::new("privacy address pattern could not be compiled"))?; + let refused = subject.is_some_and(|value| contains_sensitive(value, &address)); + Ok(Judgment { + id: ID.to_owned(), + builtin: None, + set: None, + subject: Some("tool arguments".to_owned()), + answer: if refused { + Answer::Refused(Verdict::Guard(Refusal { + id: ID.to_owned(), + report: REPORT.to_owned(), + })) + } else { + Answer::Pass + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn international_and_quoted_addresses_are_refused() { + let address = Regex::new(ADDRESS).unwrap(); + for text in [ + "person+tag@example.test", + "\"person name\"@example.test", + "\u{7528}\u{6237}@\u{4f8b}.test", + ] { + assert!(sensitive(text, &address), "{text}"); + } + } + + #[test] + fn nested_encoding_is_bounded_and_refused_instead_of_cleared() { + let address = Regex::new(ADDRESS).unwrap(); + let mut text = "person%40example.test".to_owned(); + for _ in 0..12 { + assert!(sensitive(&text, &address)); + text = text.replace('%', "%25"); + } + assert!(!sensitive("https://example.test/a%20b%3Fc%3Dd", &address)); + } +} diff --git a/src/main.rs b/src/main.rs index d3455b1..a3087de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -146,6 +146,7 @@ usage: uphold rules --effective [--json] every rule this repository resolves to, and the git hooks each one fires at uphold hook claude-code|codex judge a pending tool call or a turn's end, read on stdin + [--privacy] refuse email-shaped arguments without publication policy [--junit DIR] uphold shim [args...] check what a command would publish, then run it uphold shim --install [COMMAND...] link this binary under each command's name @@ -574,7 +575,7 @@ fn run() -> Result { ))), }, "hook" => match hook_arguments(rest)? { - (Some(harness), junit_flag) => { + (Some(harness), junit_flag, privacy) => { // Before the harness is looked up or the event read, as every // seam judges it: a value that cannot be honoured is exit 2 while // nothing has been decided. A directory only, since a harness @@ -591,10 +592,15 @@ fn run() -> Result { // everywhere and get the hook switched off within a day. What // still runs without a policy, and what does not, is // `hook::run`'s to do and to report. - hook::run(text_of(harness)?, discover(&working).as_ref(), evidence) + hook::run( + text_of(harness)?, + discover(&working).as_ref(), + evidence, + privacy, + ) } - (None, _) => Err(Fatal::new(format!( - "usage: uphold hook {} [--junit DIR]\n\n{USAGE}", + (None, _, _) => Err(Fatal::new(format!( + "usage: uphold hook {} [--privacy] [--junit DIR]\n\n{USAGE}", hook::known() ))), }, @@ -784,15 +790,16 @@ fn scan_command(arguments: &[OsString]) -> Result { Ok(exit) } -/// The harness `uphold hook` was named, and the `--junit` value beside it. +/// The harness, evidence directory, and privacy mode requested for a hook. /// /// The flag is accepted here and not at the shim because this command line is /// the harness's settings file, which the operator writes. `None` for the /// harness where there is not exactly one, which the caller answers with usage. -fn hook_arguments(rest: &[OsString]) -> Result<(Option<&OsString>, Option<&OsStr>)> { +fn hook_arguments(rest: &[OsString]) -> Result<(Option<&OsString>, Option<&OsStr>, bool)> { let mut harness = None; let mut junit_flag = None; let mut extra = false; + let mut privacy = false; let mut words = rest.iter(); while let Some(word) = words.next() { if word == junit::FLAG { @@ -800,13 +807,15 @@ fn hook_arguments(rest: &[OsString]) -> Result<(Option<&OsString>, Option<&OsStr .next() .ok_or_else(|| Fatal::new(format!("{} needs a value", junit::FLAG)))?; junit_flag = Some(value.as_os_str()); + } else if word == "--privacy" && !privacy { + privacy = true; } else if harness.is_none() { harness = Some(word); } else { extra = true; } } - Ok((harness.filter(|_| !extra), junit_flag)) + Ok((harness.filter(|_| !extra), junit_flag, privacy)) } fn guard_command(arguments: &[OsString]) -> Result { diff --git a/tests/claude_plugin.rs b/tests/claude_plugin.rs index b713ff7..2657227 100644 --- a/tests/claude_plugin.rs +++ b/tests/claude_plugin.rs @@ -22,6 +22,7 @@ use std::path::Path; use serde_json::{Value, json}; const COMMAND: &str = "uphold hook claude-code"; +const PRIVACY_COMMAND: &str = "uphold hook claude-code --privacy"; const BLOCKING_EVENTS: &[&str] = &["PreToolUse", "Stop"]; /// Every way `hooks` departs from the two properties, one line each. @@ -44,7 +45,7 @@ fn refusals(hooks: &Value) -> Vec { .flatten(); for handler in handlers { let command = handler.get("command").and_then(Value::as_str); - if command != Some(COMMAND) { + if command != Some(COMMAND) && command != Some(PRIVACY_COMMAND) { found.push(format!("{event}: runs {command:?}, not {COMMAND:?}")); } if BLOCKING_EVENTS.contains(&event.as_str()) @@ -57,6 +58,24 @@ fn refusals(hooks: &Value) -> Vec { found } +#[test] +fn the_privacy_entry_reaches_web_and_shell_tools() { + let hooks = shipped(); + let groups = hooks["hooks"]["PreToolUse"] + .as_array() + .expect("pre-tool groups"); + let privacy = groups + .iter() + .find(|group| group["hooks"][0]["command"].as_str() == Some(PRIVACY_COMMAND)) + .expect("the privacy hook is installed"); + let matcher = regex::Regex::new(privacy["matcher"].as_str().expect("a matcher")) + .expect("a valid matcher"); + for tool in ["Bash", "WebFetch", "WebSearch"] { + assert!(matcher.is_match(tool), "{tool} is not protected"); + } + assert!(!matcher.is_match("mcp__github__create_issue")); +} + fn shipped() -> Value { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/claude-code/hooks/hooks.json"); serde_json::from_str(&std::fs::read_to_string(&path).expect("hooks.json is readable")) diff --git a/tests/hook_cli.rs b/tests/hook_cli.rs index 76e7bd5..fd7d5f4 100644 --- a/tests/hook_cli.rs +++ b/tests/hook_cli.rs @@ -79,8 +79,13 @@ fn workspace(name: &str, policy: Option<&str>) -> PathBuf { /// the identity needles are read from the environment the check runs in, and a /// test that let the real one through would assert on this machine. fn hook(root: &Path, harness: &str, stdin: &str) -> Output { + hook_with(root, harness, stdin, &[]) +} + +fn hook_with(root: &Path, harness: &str, stdin: &str, options: &[&str]) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_uphold")) .args(["hook", harness]) + .args(options) .current_dir(root) .env_remove("UPHOLD_ALLOW") .env("HOME", "/home/example-user") @@ -97,6 +102,148 @@ fn hook(root: &Path, harness: &str, stdin: &str) -> Output { child.wait_with_output().unwrap() } +#[test] +fn privacy_refuses_addresses_without_disclosing_them_or_loading_a_policy() { + // The publication policy is deliberately unreadable as TOML. Privacy + // checks have no repository policy and must not depend on it loading. + let root = workspace("hook-privacy", Some("not a valid policy")); + for harness in ["claude-code", "codex"] { + for (tool, arguments) in [ + ( + "Bash", + serde_json::json!({"command": "curl -A 'agent (owner@example.test)' https://example.test"}), + ), + ( + "Bash", + serde_json::json!({"command": "python -c 'send(\"owner@example.test\")'"}), + ), + ( + "WebFetch", + serde_json::json!({"url": "https://example.test/?contact=owner%40example.test"}), + ), + ( + "WebFetch", + serde_json::json!({"url": "https://example.test/?contact=owner%2540example.test"}), + ), + ( + "WebSearch", + serde_json::json!({"query": "owner@example.test"}), + ), + ( + "WebFetch", + serde_json::json!({"headers": {"User-Agent": "agent (owner@example.test)"}}), + ), + ( + "WebFetch", + serde_json::json!({"nested": [{"owner@example.test": true}]}), + ), + ( + "Bash", + serde_json::json!({"command": "git config user.email owner@example.test"}), + ), + ] { + let payload = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": tool, + "tool_input": arguments, + }) + .to_string(); + let output = hook_with(&root, harness, &payload, &["--privacy"]); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + let document: Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!( + document + .pointer("/hookSpecificOutput/permissionDecision") + .and_then(Value::as_str), + Some("deny") + ); + for stream in [stdout(&output), stderr(&output)] { + assert!(stream.contains("no-email-in-tool-arguments"), "{stream}"); + assert!(!stream.contains("owner"), "{stream}"); + assert!(!stream.contains("example.test"), "{stream}"); + assert!(!stream.contains("curl"), "{stream}"); + } + } + } +} + +#[test] +fn privacy_allows_ordinary_calls_silently_and_refuses_an_unread_subject() { + let root = workspace("hook-privacy-clean", None); + for arguments in [ + serde_json::json!({"command": "curl https://example.test"}), + serde_json::json!({"url": "https://example.test/a%20b", "query": "documentation"}), + serde_json::json!({"count": 3, "enabled": true, "empty": null}), + ] { + let payload = serde_json::json!({ + "hook_event_name": "PreToolUse", "tool_name": "WebFetch", "tool_input": arguments, + }) + .to_string(); + let output = hook_with(&root, "claude-code", &payload, &["--privacy"]); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert_eq!(stdout(&output), ""); + assert_eq!(stderr(&output), ""); + } + for payload in [ + "not JSON", + r#"{"hook_event_name":"PreToolUse","tool_name":"WebFetch"}"#, + r#"{"hook_event_name":"Stop","tool_input":{}}"#, + ] { + let output = hook_with(&root, "claude-code", payload, &["--privacy"]); + assert_eq!(code(&output), 2); + assert_ne!(stderr(&output), ""); + } +} + +#[test] +fn privacy_checks_json_escaped_addresses() { + let root = workspace("hook-privacy-json-escapes", None); + let payload = event("WebSearch", "owner@example.test").replace('@', r"\u0040"); + let output = hook_with(&root, "claude-code", &payload, &["--privacy"]); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + let document: Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!( + document + .pointer("/hookSpecificOutput/permissionDecision") + .and_then(Value::as_str), + Some("deny") + ); + assert!(!stdout(&output).contains("owner")); +} + +#[test] +fn privacy_records_both_outcomes_without_the_address() { + let root = workspace("hook-privacy-evidence", None); + let directory = root.join("evidence"); + std::fs::create_dir(&directory).unwrap(); + for body in ["ordinary request", "owner@example.test"] { + let output = hook_with( + &root, + "claude-code", + &event("WebSearch", body), + &["--privacy", "--junit", directory.to_str().unwrap()], + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + } + let records: Vec = std::fs::read_dir(&directory) + .unwrap() + .map(|entry| std::fs::read_to_string(entry.unwrap().path()).unwrap()) + .collect(); + assert_eq!(records.len(), 2); + assert_eq!( + records + .iter() + .filter(|record| record.contains(" String { String::from_utf8_lossy(&output.stdout).into_owned() } From 04da61b61d352ed79e06e4c589a21962cc5d49b8 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sun, 8 Nov 2026 15:31:51 +0900 Subject: [PATCH 2/4] Judge shell and web tool calls through the hook's one rule path `uphold hook --privacy` was a second judgment beside `hook::run`: its own record, its own refusal and one email-shaped pattern compiled into the binary, reached by a flag and by nothing in policy. The pattern refused `git@github.com`, `react@18` and `image@sha256:...` before every shell command, and no repository could exempt anything short of deleting the matcher. The calls now reach the ordinary hook. A compiled-in `[[hook]]` table marks `Bash`, `WebFetch` and `WebSearch` with `publishes = false`: the literal rules and the pattern rules written for `seams = ["hook"]` judge them, and the guards and prose rules, which are about a forge publication, are recorded as out of scope. The compiled-in tables are read where no policy is too, and the partial-coverage notice is not printed for a call that publishes nothing. What is refused is the running user's own address: a new literal source, `running-user-email`, reads every `user.email` git resolves and `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_EMAIL` and `EMAIL`, and a fallback rule, `no-running-user-email`, runs it at the hook seam only. Its needles are withheld, so the refusal and the JUnit record carry `[REDACTED_MATCH]` instead of the line. A repository shadows the rule with `ignore_literals`, or refuses every address with a hook-seam `regexp` rule. Not at `commit-msg` or the shim, where a `Signed-off-by` trailer and a published maintainer address are the author's decision. Every string a call carries is also judged through up to four layers of percent-decoding, in the hook rather than in `walk_strings`, which also reads the `gh api --input` document a forge stores as written. Removed with the flag: `src/hook/privacy.rs`, and `hook_with`, the test helper that passed it, now `hook_as`, which also isolates git's configuration and the mail variables. The tests of the flag are replaced by tests of the ordinary path: `privacy_refuses_addresses_without_disclosing_them_or_loading_a_policy` by `the_running_users_address_is_refused_and_withheld`, `privacy_allows_ordinary_calls_silently_and_refuses_an_unread_subject` by `email_shaped_text_and_publication_guards_do_not_stop_a_shell_call` (the malformed-event half is the existing suite's), `privacy_records_both_outcomes_without_the_address` by `the_evidence_withholds_the_address`, and `the_privacy_entry_reaches_web_and_shell_tools` by `the_shell_and_web_tools_reach_the_hook`. `privacy_checks_json_escaped_addresses` is gone: `@` is decoded by the JSON reader before any rule sees a string, at every hook call alike. Claude-Session: https://claude.ai/code/session_01EycJ7monkqAU8KKjL7Qp3L --- Cargo.toml | 3 +- README.md | 17 +- docs/REFERENCE.md | 117 +++++----- docs/schema/principles.schema.json | 5 + plugins/claude-code/hooks/hooks.json | 2 +- policy/hook.default.toml | 12 ++ src/config.rs | 12 ++ src/config/rule.rs | 3 +- src/guard/mod.rs | 9 +- src/hook.rs | 86 +++++--- src/hook/privacy.rs | 104 --------- src/hook/table.rs | 63 ++++++ src/main.rs | 23 +- src/sources.rs | 66 +++++- src/text.rs | 70 +++++- tests/claude_plugin.rs | 23 +- tests/hook_cli.rs | 311 +++++++++++++++++---------- 17 files changed, 570 insertions(+), 356 deletions(-) delete mode 100644 src/hook/privacy.rs diff --git a/Cargo.toml b/Cargo.toml index f93f89f..ad1057b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,8 @@ ignore = "0.4" # The engine under `grep-regex`, used directly where a pattern is matched # against a string already in memory rather than searched through a file. regex = "1" -# Decode URL-encoded tool arguments before the privacy hook examines them. +# Read a tool call's strings through their percent-encoding (RFC 3986 section +# 2.1) at `uphold hook`, so a URL carrying `%40` is judged as what it decodes to. percent-encoding = "2" # Where a URL written out in prose ends and the sentence's punctuation begins, # for `citations-linked`. A URL pattern of our own gets that boundary wrong on diff --git a/README.md b/README.md index 6c90f0c..8e1f85c 100644 --- a/README.md +++ b/README.md @@ -338,14 +338,15 @@ second matcher in this binary. An unknown harness name is refused rather than guessed at, because where to find the text in its event cannot be derived from its name. -The Claude Code plugin also runs `uphold hook claude-code --privacy` before -`Bash`, `WebFetch` and `WebSearch`. It refuses email-shaped text in arguments, -including headers and URL-encoded strings, without logging the address or -reading account credentials. Shell checks are conservative: a local command -containing an address is refused too. This checks visible arguments only; -headers inserted later by the client, scripts, environment variables and -hosted tools that do not invoke the hook need protection at their own boundary. -See [the privacy hook contract](docs/REFERENCE.md#privacy-before-web-and-shell-tools). +The Claude Code plugin also sends `Bash`, `WebFetch` and `WebSearch` through +the same hook. Those calls publish nothing to a forge, so a compiled-in +`[[hook]]` table judges them only by the rules about this machine and its user: +host identity, and the running user's own email address as git and the +environment configure it, read through URL encoding and withheld from the +refusal and the evidence. `git@github.com` and `react@18` are nobody's address +and pass. This checks the arguments the harness hands over, not the request a +client eventually sends. See +[the shell and web tools](docs/REFERENCE.md#shell-and-web-tools). For Claude Code, enable the `uphold` plugin this repository ships, at the tag of the release you installed: diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 9565325..1fe8664 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -1152,7 +1152,9 @@ files.word = true The name says what fails: a literal describing **this machine** — username, home path, hostname and its identifying segments, default-route addresses — found in content. Sources: `running-os-identity`, `running-os-metadata` -(identity plus route), `running-default-route`. `forbidden_literals_from` +(identity plus route), `running-default-route`, and `running-user-email` (every +`user.email` git resolves, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_EMAIL` and +`EMAIL`, whose matches are always reported as `[REDACTED_MATCH]`). `forbidden_literals_from` names any command producing one literal per line (`labelvalue`, or a bare line as its own label): @@ -3244,12 +3246,13 @@ the folder needs no install of their own: } ``` -The plugin's `hooks/hooks.json` holds the publication and privacy checks: +The plugin's `hooks/hooks.json` holds four entries, each running +`uphold hook claude-code`: | event | matcher | options | |---|---|---| | `PreToolUse` | `mcp__github__.*` | `onFailure: "block"` | -| `PreToolUse` | `^(Bash\|WebFetch\|WebSearch)$` | `--privacy`, `onFailure: "block"` | +| `PreToolUse` | `^(Bash\|WebFetch\|WebSearch)$` | `onFailure: "block"` | | `SessionStart` | | `async: true` | | `Stop` | | `timeout: 900`, `onFailure: "block"` | @@ -3262,58 +3265,65 @@ remove it. A cloud session does not add marketplaces from a repository's `extraKnownMarketplaces`, so the plugin does not reach one through project settings. -### Privacy before web and shell tools +### Shell and web tools -`uphold hook claude-code --privacy` reads the same pre-call JSON and returns -the same denial document as the publication hook. The shipped Claude Code -plugin installs it for `Bash`, `WebFetch` and `WebSearch`. An inline installation -adds this group to `hooks.PreToolUse`: +The `Bash`, `WebFetch` and `WebSearch` entry reaches the same judgment as every +other call, through a compiled-in `[[hook]]` table that says these calls +publish nothing to a forge: -```json -{ - "matcher": "^(Bash|WebFetch|WebSearch)$", - "hooks": [{"type": "command", "command": "uphold hook claude-code --privacy", "onFailure": "block"}] -} +```toml +[[hook]] +tools = '^(Bash|WebFetch|WebSearch)$' +publishes = false +``` + +So they are judged by the rules that mean the same against any text: the +literal rules, and the pattern rules a policy writes for `seams = ["hook"]`. +The guards and the prose rules are about a publication and are recorded as out +of scope, so a private repository's name in a `cd` and an authorship marker in +a drafted commit message are not refused here. They are refused when the text +is published, at the shim or the forge's hook. + +Two literal rules run wherever the call is made, policy or none: +`no-running-os-identity-metadata`, and at this seam only `no-running-user-email`, +which reads `running-user-email`. The second refuses the running user's own +address. It does not refuse every email-shaped string: an SSH remote, a package +pin and an image digest look like addresses and identify nobody. A refusal and +its `--junit` record name the rule and where the address was configured, never +the address or the line it was on. Each string the call carries is also read +through up to four layers of percent-encoding (RFC 3986 section 2.1), so +`owner%40example.test` and `owner%2540example.test` are found. + +A repository changes either end in policy: + +```toml +# A published security contact is the user's address on purpose. +[rule.no-running-user-email] +message = "Leave the address out." +forbidden_literals = "running-user-email" +ignore_literals = ["security@example.test"] +files.include = ["."] + +# Every address, whoever's, in a tool call. +[rule.no-address-in-tool-calls] +message = "Leave addresses out." +regexp = '[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}' +seams = ["hook"] +command.before = ["gh"] +command.scope = "always" ``` -Privacy mode checks every string value and object key in `tool_input`, including -nested headers, URL parameters, bodies and search queries. It refuses text shaped -like an email address: dot-atom characters from RFC 5322 section 3.2.3, quoted -local parts, and Unicode letters and numbers as in SMTPUTF8 (RFC 6531). This is -a conservative detector, not validation of every mailbox syntax. It requires -no private identity list or account credential access and protects addresses -whether they belong to the operator or someone else. - -JSON escapes are decoded by the event reader. URL percent encoding is decoded -through a bounded number of layers; input that still changes after that bound -is refused rather than reported as inspected. Ordinary calls return silently. -A refusal names `no-email-in-tool-arguments` without printing the tool name, -arguments or address. `--junit DIR` records passes and refusals with the same -content withholding. The check runs independently of repository publication -policy and `UPHOLD_ALLOW`; selecting privacy mode always runs it. A malformed -event, absent `tool_input`, unsupported harness or event other than -`PreToolUse` exits `2`. Keep the blocking failure option in the installation. - -Shell commands are checked in full, including local commands: an email address -in `git config`, an SSH login or a mail command is also refused. Make intentional -identity configuration or email delivery outside the agent's guarded shell. -This avoids trying to infer arbitrary shell code's network effects from its -spelling. The publication hook remains a separate entry so privacy checks do -not apply forge-write policy to shell commands or replace existing checks. - -The guarantee is limited to the arguments the harness supplies. This hook -cannot inspect headers injected by an HTTP client after the hook, evaluate -shell concatenation, decode arbitrary encodings, read script or upload-file -contents, inspect environment variables, or observe traffic from tools that -bypass local hooks. It does not detect every kind of personal information or -credential. Protect those paths in the sending client or an enforced outbound -proxy. A passing hook is not an attestation that the eventual network request -contains no personal information. - -`--privacy` also accepts the shared `codex` event protocol. Its shipped plugin -does not install this mode; an operator must attach it to the applicable local -tools. Hosted web tools that do not emit local hook events remain outside its -reach. +Both rules, and the user-email fallback, also judge the GitHub MCP server's +calls, which are the hook seam too. The shim and `--text` seams do not get the +fallback: a `Signed-off-by` trailer at `commit-msg` is the author's decision. A +repository that wants it there declares the rule. + +The check reads the arguments the harness supplies. It cannot see headers an +HTTP client adds after the hook, shell concatenation, the contents of a script +or an uploaded file, environment variables, or tools that never call a local +hook. A pass is not an attestation about the request that is eventually sent. +The Codex plugin's matcher is `mcp__.*`, so it does not send its shell tool +here. ### Installing the Codex plugin @@ -3417,6 +3427,8 @@ shim = "gh" # take scope and unresolved from this [[shim]] table # or, instead of shim: # scope = "public-target" # as on [[shim]]; "always" where neither is written # unresolved = "refuse" # as on [[shim]]; "run" is the opt-out +# or, for a call that publishes to no forge, with none of the above: +# publishes = false # judged by literal rules and seams = ["hook"] patterns only ``` That table is compiled in, from @@ -3479,6 +3491,9 @@ configured for. A table in an the scope is right at the shim, but that scope reads an npm command line, which no tool call has: at the hook it is unresolved, and the refusal names the rule. A pattern or prose rule takes `seams = ["shim"]` to stay off the hook. +`publishes = false` beside `skip`, `target`, `forge`, `owner`, `repo`, `shim`, +`scope` or `unresolved` is refused: those say where a publication goes, and +the table says there is none. See [Shell and web tools](#shell-and-web-tools). ### What runs where there is no policy diff --git a/docs/schema/principles.schema.json b/docs/schema/principles.schema.json index 97899b7..9800ee0 100644 --- a/docs/schema/principles.schema.json +++ b/docs/schema/principles.schema.json @@ -184,6 +184,11 @@ "description": "JSON pointer (RFC 6901) into the call's input, to the owner. Not the\nempty pointer, which names the whole input: an object is never an\nowner's name.", "type": "string" }, + "publishes": { + "default": true, + "description": "`false` where these calls publish nothing to a forge, but what they\ncarry still leaves the session: a shell command, a fetched URL, a\nsearch query. Only the rules that mean the same against any text judge\nthem -- the literal rules, which read the running host and user, and\nthe pattern rules whose `seams` names `hook`. The guards and the prose\nrules are about a forge publication and are recorded as out of scope:\na private repository's name in a `cd` is not that name published.\n`true` (the default) is every other table. Not written beside `skip`,\n`target`, `shim`, `scope` or `unresolved`, which say where a\npublication goes and which of its rules stand there.", + "type": "boolean" + }, "repo": { "description": "JSON pointer (RFC 6901) into the call's input, to the repository. Not\nthe empty pointer, as for `owner`.", "type": "string" diff --git a/plugins/claude-code/hooks/hooks.json b/plugins/claude-code/hooks/hooks.json index 4cd7b3b..731aca6 100644 --- a/plugins/claude-code/hooks/hooks.json +++ b/plugins/claude-code/hooks/hooks.json @@ -4,7 +4,7 @@ { "matcher": "^(Bash|WebFetch|WebSearch)$", "hooks": [ - {"type": "command", "command": "uphold hook claude-code --privacy", "onFailure": "block"} + {"type": "command", "command": "uphold hook claude-code", "onFailure": "block"} ] }, { diff --git a/policy/hook.default.toml b/policy/hook.default.toml index 60a8fa5..50016cb 100644 --- a/policy/hook.default.toml +++ b/policy/hook.default.toml @@ -101,3 +101,15 @@ forge = "github" owner = "/owner" repo = "/repo" shim = "gh" + +# A harness's own shell and web tools: Claude Code's `Bash`, `WebFetch` and +# `WebSearch`. They publish nothing to a forge, and what they carry still +# leaves the session -- to a process, a fetched host, a search provider. So +# `publishes = false`: the literal rules judge them, which read this machine's +# identity and the running user's own addresses and mean the same against any +# text, as do the pattern rules a policy writes with `seams = ["hook"]`. The +# guards and prose rules are about a publication and are recorded as out of +# scope. A private repository's name in a `cd` is not that name published. +[[hook]] +tools = '^(Bash|WebFetch|WebSearch)$' +publishes = false diff --git a/src/config.rs b/src/config.rs index 630d924..347934c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2946,6 +2946,18 @@ fn hook_defaults() -> Result> { .map_err(|error| Fatal::new(format!("the compiled-in `[[hook]]` tables: {error}"))) } +/// The compiled-in `[[hook]]` tables alone, for a hook call made where no +/// policy is: which calls publish is a fact about the tools, and holds whether +/// or not anybody wrote a policy where the session started. +pub(crate) fn compiled_hooks() -> Result> { + load_hooks( + Path::new("policy/hook.default.toml"), + &[], + None, + hook_defaults()?, + ) +} + /// Each `[[hook]]` table parsed into the shape that carries its checks, and /// `unresolved` held to the reading [`validate_shim_unresolved`] gives it on /// `[[shim]]`: written only where some scope this table's calls reach can fail diff --git a/src/config/rule.rs b/src/config/rule.rs index b87542e..1962c1c 100644 --- a/src/config/rule.rs +++ b/src/config/rule.rs @@ -224,7 +224,8 @@ pub(crate) struct Written { #[serde(untagged)] pub(crate) enum Literals { /// A named built-in source of literals describing the running machine -- - /// `running-os-identity`, `running-os-metadata`, `running-default-route`. + /// `running-os-identity`, `running-os-metadata`, `running-default-route`, + /// `running-user-email`. /// The name says what fails: a literal describing THIS machine, found in /// content. Named { forbidden_literals: String }, diff --git a/src/guard/mod.rs b/src/guard/mod.rs index 4de91f2..e8cd9f5 100644 --- a/src/guard/mod.rs +++ b/src/guard/mod.rs @@ -665,7 +665,14 @@ pub(crate) fn text_refusal( // not a policy rule at all. Gating them on the table's scope would only // stand the identity check down for private destinations, which is the // arrangement `command.scope = "always"` on this rule exists to undo. - "text-literals" => crate::text::failures_in(root, policy, text, Some(kind), ledger), + "text-literals" => crate::text::failures_in( + crate::text::Seam::Guard, + root, + policy, + text, + Some(kind), + ledger, + ), _ => Ok(()), } } diff --git a/src/hook.rs b/src/hook.rs index 3f3ab75..ef1eebb 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -53,7 +53,6 @@ //! the end-of-turn gate in [`stop`]; every other event, and an event naming //! none, is the pre-call judgment below, as it was before the gate existed. -mod privacy; mod stop; mod table; @@ -170,6 +169,41 @@ fn drop_unsent(collected: &mut Walked) { .retain(|(key, text)| !(key == "commit_title" && text.is_empty())); } +/// How many percent-decoding layers a string is read through. +/// +/// A URL carries an address as `person%40example.test`, and a query string +/// built from one can carry it as `%2540`. Each layer that changes the text is +/// judged beside the text as written, so a literal or a pattern finds the +/// value however many times it was encoded, up to this bound. Bounded because +/// each layer is another copy of the string: past four, an encoding is not a +/// URL any client built, and the text as written is still judged. +const DECODED_LAYERS: usize = 4; + +/// Add, after the strings a call carries, each percent-decoded layer of them +/// that differs from the one before (RFC 3986 section 2.1). +/// +/// Here and not in `walk_strings`, which also reads the document a shim's +/// `gh api --input` sends: that document is published as written, and what a +/// forge stores is the undecoded text. A tool call's URL or query is decoded +/// by whoever receives it, so what it discloses is what it decodes to. +fn decoded_layers(prose: &mut Vec) { + let mut layers = Vec::new(); + for text in prose.iter() { + let mut current = text.clone(); + for _ in 0..DECODED_LAYERS { + let next = percent_encoding::percent_decode_str(¤t) + .decode_utf8_lossy() + .into_owned(); + if next == current { + break; + } + layers.push(next.clone()); + current = next; + } + } + prose.extend(layers); +} + /// Judge one pending tool call, read from stdin in the harness's own shape. /// /// `found` is the policy where the call was made, and it is an `Option` here @@ -189,7 +223,6 @@ pub(crate) fn run( harness: &str, found: Option<&(PathBuf, PathBuf)>, evidence: Option, - privacy: bool, ) -> Result { let shape = SHAPES .iter() @@ -216,12 +249,6 @@ pub(crate) fn run( )) })?; - if privacy && event.get("hook_event_name").and_then(Value::as_str) != Some("PreToolUse") { - return Err(Fatal::new( - "--privacy requires a PreToolUse event; no tool arguments were checked", - )); - } - if let Some(name @ ("SessionStart" | "Stop")) = event.pointer("/hook_event_name").and_then(Value::as_str) { @@ -279,31 +306,6 @@ pub(crate) fn run( ); let subject = observation.read()?.copied(); - // A separate matcher selects privacy calls. They are checked without - // repository publication policy: a shell command is not a forge write. - if privacy { - let mut record = Record::new( - evidence.map(Place::Directory), - Run::Hook(harness.to_owned()), - ); - record.open()?; - let judgment = match privacy::judge(subject) { - Ok(judgment) => judgment, - Err(error) => return record.finish(Err(error)), - }; - let report = judgment.refused().map(|verdict| match verdict { - Verdict::Guard(refusal) => refusal.report.clone(), - Verdict::Rule(failure) => failure.report(), - }); - record.ledger.push(judgment); - record.write(false)?; - if let Some(report) = report { - eprintln!("{report}"); - println!("{}", refusal_document(shape, &report)?); - } - return Ok(shape.refused); - } - // Everything a piece of published text is judged by, from the one place // that knows what that is. The host-identity rules run wherever the call // was made from, policy or no policy: `text::load_for` carries an empty @@ -330,6 +332,10 @@ pub(crate) fn run( // down as set aside rather than asked. let named = policy.hooks.iter().find(|table| table.names(label)); let read = named.is_some_and(|table| table.skips(label)); + // A shell command, a fetched URL, a search query: text that leaves the + // session and reaches no forge. The rules about this machine and its user + // read it; the rules about a publication stand down, recorded as such. + let unpublished = named.is_some_and(|table| !table.publishes()); let mut scoping = table::Scoping::new(named, &policy.shims, label, subject, &root); let destination = if read { None @@ -342,6 +348,7 @@ pub(crate) fn run( walk_strings(subject, None, &mut collected); } drop_unsent(&mut collected); + decoded_layers(&mut collected.prose); let has_text = !collected.prose.is_empty() || !collected.headlines.is_empty(); let text = collected.prose.join("\n"); let headlines: Vec = collected @@ -383,9 +390,16 @@ pub(crate) fn run( // On a read, only a pattern rule about the tool name is in scope: the // text rules were set aside above, and a pattern rule about text has // none to read, so it is recorded as set aside rather than as a pass. + // + // On a call that publishes nothing, only the pattern rules a policy + // wrote for this seam are asked beside the literal rules, which are + // never asked for their scope: a guard or a prose rule is about a + // publication, and there is none. &mut |rule| { if read { Ok(rule.selects_subject("tool")) + } else if unpublished && text::Judged::of(rule) != Some(text::Judged::Patterns) { + Ok(false) } else { scoping.holds(rule) } @@ -421,8 +435,10 @@ pub(crate) fn run( // enclosing superproject's is not borrowed. Where there is no policy the // guards and the prose rules have nothing to run, and saying so on stderr // is the difference between a check that passed and a check that did not - // happen. - if found.is_none() { + // happen. Not for a call that publishes nothing, where the guards and the + // prose rules are out of scope with a policy or without one: saying so on + // every shell command would be the narration that gets a matcher deleted. + if found.is_none() && !unpublished { eprintln!( "uphold hook: no policy where this call was made, so the guards did not run \ and only the host-identity rules did. That is partial coverage, not a pass." diff --git a/src/hook/privacy.rs b/src/hook/privacy.rs deleted file mode 100644 index ba4ac00..0000000 --- a/src/hook/privacy.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Email-shaped text in pending arguments, before a web or shell tool runs. -//! -//! This is a conservative disclosure check, not a mailbox validator or a -//! network sandbox. It never reads account credentials and never quotes the -//! subject, even in evidence. Hidden headers and script contents are outside -//! the pre-call event and cannot be certified by inspecting it. - -use regex::Regex; -use serde_json::Value; - -use crate::error::{Fatal, Result}; -use crate::guard::Refusal; -use crate::text::{Answer, Judgment, Verdict}; - -const ID: &str = "no-email-in-tool-arguments"; -const REPORT: &str = "uphold refused: no-email-in-tool-arguments. Email-shaped text was found in the pending tool arguments, or URL encoding exceeded the inspection limit. The address and arguments are withheld. Remove personal information before retrying. No tool call was allowed by this hook."; - -// RFC 5322 section 3.2.3's dot-atom characters, with letters and numbers -// extended for SMTPUTF8 (RFC 6531). Deliberately accepts more than a valid -// mailbox: malformed addresses can still identify a person. This does not -// attempt to reconstruct shell expressions. -const ADDRESS: &str = r#"(?:"[^"\r\n]+"|[\p{L}\p{N}.!#$%&'*+/=?^_`{|}~-]+)@[\p{L}\p{N}](?:[\p{L}\p{N}.-]*[\p{L}\p{N}])?"#; - -fn sensitive(text: &str, address: &Regex) -> bool { - let mut decoded = text.to_owned(); - // A fixed bound avoids quadratic work on adversarial nested encoding. - // If another layer remains after it, refuse rather than clear unread text. - for _ in 0..4 { - if address.is_match(&decoded) { - return true; - } - let next = percent_encoding::percent_decode_str(&decoded) - .decode_utf8_lossy() - .into_owned(); - if next == decoded { - return false; - } - decoded = next; - } - address.is_match(&decoded) - || percent_encoding::percent_decode_str(&decoded).decode_utf8_lossy() != decoded -} - -fn contains_sensitive(subject: &Value, address: &Regex) -> bool { - match subject { - Value::String(text) => sensitive(text, address), - Value::Array(values) => values - .iter() - .any(|value| contains_sensitive(value, address)), - Value::Object(values) => values - .iter() - .any(|(key, value)| sensitive(key, address) || contains_sensitive(value, address)), - _ => false, - } -} - -/// Judge every string and object key without retaining the matched content. -pub(super) fn judge(subject: Option<&Value>) -> Result { - let address = Regex::new(ADDRESS) - .map_err(|_| Fatal::new("privacy address pattern could not be compiled"))?; - let refused = subject.is_some_and(|value| contains_sensitive(value, &address)); - Ok(Judgment { - id: ID.to_owned(), - builtin: None, - set: None, - subject: Some("tool arguments".to_owned()), - answer: if refused { - Answer::Refused(Verdict::Guard(Refusal { - id: ID.to_owned(), - report: REPORT.to_owned(), - })) - } else { - Answer::Pass - }, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn international_and_quoted_addresses_are_refused() { - let address = Regex::new(ADDRESS).unwrap(); - for text in [ - "person+tag@example.test", - "\"person name\"@example.test", - "\u{7528}\u{6237}@\u{4f8b}.test", - ] { - assert!(sensitive(text, &address), "{text}"); - } - } - - #[test] - fn nested_encoding_is_bounded_and_refused_instead_of_cleared() { - let address = Regex::new(ADDRESS).unwrap(); - let mut text = "person%40example.test".to_owned(); - for _ in 0..12 { - assert!(sensitive(&text, &address)); - text = text.replace('%', "%25"); - } - assert!(!sensitive("https://example.test/a%20b%3Fc%3Dd", &address)); - } -} diff --git a/src/hook/table.rs b/src/hook/table.rs index c34ff83..7f57a6b 100644 --- a/src/hook/table.rs +++ b/src/hook/table.rs @@ -75,6 +75,22 @@ pub(crate) struct Written { /// default) or `run`. As on `[[shim]]`. #[serde(default)] unresolved: Option, + /// `false` where these calls publish nothing to a forge, but what they + /// carry still leaves the session: a shell command, a fetched URL, a + /// search query. Only the rules that mean the same against any text judge + /// them -- the literal rules, which read the running host and user, and + /// the pattern rules whose `seams` names `hook`. The guards and the prose + /// rules are about a forge publication and are recorded as out of scope: + /// a private repository's name in a `cd` is not that name published. + /// `true` (the default) is every other table. Not written beside `skip`, + /// `target`, `shim`, `scope` or `unresolved`, which say where a + /// publication goes and which of its rules stand there. + #[serde(default = "publishes_by_default")] + publishes: bool, +} + +const fn publishes_by_default() -> bool { + true } /// Where a `[[hook]]` table's calls are going: the `[[shim]]` targets a tool @@ -132,6 +148,7 @@ pub(crate) struct Table { skip: std::collections::BTreeSet, destination: Option, pub(crate) answers: Answers, + publishes: bool, } impl Written { @@ -148,6 +165,23 @@ impl Written { }) }; let tools = compile(&self.tools)?; + if !self.publishes + && (!self.skip.is_empty() + || self.target != HookTarget::None + || self.forge.is_some() + || self.owner.is_some() + || self.repo.is_some() + || self.shim.is_some() + || self.scope.is_some() + || self.unresolved.is_some()) + { + return Err(format!( + "the `[[hook]]` for {tools_text:?} says `publishes = false` beside `skip`, \ + `target`, `forge`, `owner`, `repo`, `shim`, `scope` or `unresolved`. Those say \ + where a publication goes and which rules stand there, and a call that \ + publishes nothing has neither; take them off, or take `publishes` off" + )); + } if let Some(name) = self.skip.iter().find(|name| { name.contains("__") || name.is_empty() @@ -233,6 +267,7 @@ impl Written { skip, destination, answers, + publishes: self.publishes, }) } } @@ -261,6 +296,11 @@ impl Table { } } + /// Whether these calls publish to a forge. See [`Written::publishes`]. + pub(crate) const fn publishes(&self) -> bool { + self.publishes + } + /// Whether this table speaks for `tool`. pub(crate) fn names(&self, tool: &str) -> bool { self.tools.is_match(tool) @@ -463,6 +503,29 @@ mod tests { written().load().unwrap() } + /// A call that publishes nothing has no destination and no reads, so a + /// table saying both is one whose second half nothing reads. + #[test] + fn a_table_that_publishes_nothing_names_no_destination() { + let quiet: Written = + toml::from_str("tools = '^(Bash|WebFetch)$'\npublishes = false\n").unwrap(); + let table = quiet.load().unwrap(); + assert!(!table.publishes()); + assert!(github().publishes()); + for extra in [ + "skip = [\"read\"]", + "target = \"forge-repo\"\nforge = \"github\"\nowner = \"/o\"\nrepo = \"/r\"", + "shim = \"gh\"", + "scope = \"always\"", + "unresolved = \"run\"", + ] { + let written: Written = + toml::from_str(&format!("tools = '^Bash$'\npublishes = false\n{extra}\n")).unwrap(); + let error = written.load().unwrap_err(); + assert!(error.contains("publishes = false"), "{extra}: {error}"); + } + } + #[test] fn a_read_is_skipped_and_a_write_is_not() { let table = github(); diff --git a/src/main.rs b/src/main.rs index a3087de..d3455b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -146,7 +146,6 @@ usage: uphold rules --effective [--json] every rule this repository resolves to, and the git hooks each one fires at uphold hook claude-code|codex judge a pending tool call or a turn's end, read on stdin - [--privacy] refuse email-shaped arguments without publication policy [--junit DIR] uphold shim [args...] check what a command would publish, then run it uphold shim --install [COMMAND...] link this binary under each command's name @@ -575,7 +574,7 @@ fn run() -> Result { ))), }, "hook" => match hook_arguments(rest)? { - (Some(harness), junit_flag, privacy) => { + (Some(harness), junit_flag) => { // Before the harness is looked up or the event read, as every // seam judges it: a value that cannot be honoured is exit 2 while // nothing has been decided. A directory only, since a harness @@ -592,15 +591,10 @@ fn run() -> Result { // everywhere and get the hook switched off within a day. What // still runs without a policy, and what does not, is // `hook::run`'s to do and to report. - hook::run( - text_of(harness)?, - discover(&working).as_ref(), - evidence, - privacy, - ) + hook::run(text_of(harness)?, discover(&working).as_ref(), evidence) } - (None, _, _) => Err(Fatal::new(format!( - "usage: uphold hook {} [--privacy] [--junit DIR]\n\n{USAGE}", + (None, _) => Err(Fatal::new(format!( + "usage: uphold hook {} [--junit DIR]\n\n{USAGE}", hook::known() ))), }, @@ -790,16 +784,15 @@ fn scan_command(arguments: &[OsString]) -> Result { Ok(exit) } -/// The harness, evidence directory, and privacy mode requested for a hook. +/// The harness `uphold hook` was named, and the `--junit` value beside it. /// /// The flag is accepted here and not at the shim because this command line is /// the harness's settings file, which the operator writes. `None` for the /// harness where there is not exactly one, which the caller answers with usage. -fn hook_arguments(rest: &[OsString]) -> Result<(Option<&OsString>, Option<&OsStr>, bool)> { +fn hook_arguments(rest: &[OsString]) -> Result<(Option<&OsString>, Option<&OsStr>)> { let mut harness = None; let mut junit_flag = None; let mut extra = false; - let mut privacy = false; let mut words = rest.iter(); while let Some(word) = words.next() { if word == junit::FLAG { @@ -807,15 +800,13 @@ fn hook_arguments(rest: &[OsString]) -> Result<(Option<&OsString>, Option<&OsStr .next() .ok_or_else(|| Fatal::new(format!("{} needs a value", junit::FLAG)))?; junit_flag = Some(value.as_os_str()); - } else if word == "--privacy" && !privacy { - privacy = true; } else if harness.is_none() { harness = Some(word); } else { extra = true; } } - Ok((harness.filter(|_| !extra), junit_flag, privacy)) + Ok((harness.filter(|_| !extra), junit_flag)) } fn guard_command(arguments: &[OsString]) -> Result { diff --git a/src/sources.rs b/src/sources.rs index 6333d62..ad79bde 100644 --- a/src/sources.rs +++ b/src/sources.rs @@ -18,6 +18,12 @@ pub(crate) struct Needle { pub label: String, pub value: String, pub word: bool, + /// The value is never reported, not even as the line it was found on. Set + /// by a source whose value is a person's contact address: a refusal and + /// its evidence are read, kept and published further than the text the + /// rule stopped, and a report quoting the address would be the disclosure. + /// The needle's label says which value matched; the value stays here. + pub withheld: bool, } /// Names that are never a personal identity leak. @@ -148,6 +154,7 @@ fn push(needles: &mut Vec, label: &str, value: Option, word: boo label: label.to_owned(), value, word, + withheld: false, }); } @@ -333,6 +340,59 @@ fn running_default_route() -> Vec { needles } +/// The addresses the running user sends mail and commits as. +/// +/// Read where the running user's tools read them: every `user.email` git +/// resolves at `root` (system, global, repository and included files, through +/// `git config --get-all`), and the variables git and mail clients take an +/// address from -- `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_EMAIL` and `EMAIL` +/// (git-commit-tree(1), "COMMIT INFORMATION"). The value lives only in this +/// process: no policy file names it, and every needle is [`Needle::withheld`], +/// so no report does either. +/// +/// Not every email-shaped string. An address is personal by whose it is, not +/// by its shape: `git@github.com`, `react@18` and `image@sha256:...` are +/// email-shaped and identify nobody, and a check that refused them on every +/// shell call would be switched off within the day. A repository that does +/// want any address refused writes a `regexp` rule with `seams = ["hook"]`. +/// +/// Searched as written and lowercased: a domain is case-insensitive (RFC 5321 +/// section 2.4), and the local part is in practice. No address configured is +/// no needles, which is an answer -- nothing here is known to be anyone's -- +/// and git failing to run at all is the could-not-look it is. +fn running_user_email(root: &Path) -> Answer { + let configured = crate::git::try_run(root, &["config", "--get-all", "user.email"]) + .map_err(|error| error.to_string())? + .unwrap_or_default(); + let mut needles: Vec = Vec::new(); + let mut seen = BTreeSet::new(); + let found = configured + .lines() + .map(|line| ("git config user.email", line.to_owned())) + .chain( + ["GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL", "EMAIL"] + .into_iter() + .filter_map(|name| env(name).map(|value| (name, value))), + ); + for (from, value) in found { + let value = value.trim(); + if !value.contains('@') { + continue; + } + for spelling in [value.to_owned(), value.to_lowercase()] { + if seen.insert(spelling.clone()) { + needles.push(Needle { + label: from.to_owned(), + value: spelling, + word: false, + withheld: true, + }); + } + } + } + Ok(needles) +} + /// Run a command and read one needle per line of its stdout. /// /// This is what replaced `policy/sources.py`. A repository that needed a custom @@ -386,6 +446,7 @@ fn command_source(run: &str, root: &Path) -> Answer { enum Question { OsIdentity, DefaultRoute, + UserEmail, Command(String), } @@ -424,6 +485,7 @@ fn ask(question: &Question, root: &Path) -> Answer { .or_insert_with(|| match question { Question::OsIdentity => Ok(running_os_identity()), Question::DefaultRoute => Ok(running_default_route()), + Question::UserEmail => running_user_email(root), Question::Command(run) => command_source(run, root), }) .clone() @@ -447,6 +509,7 @@ pub(crate) fn resolve( let answered = match source { "running-os-identity" => ask(&Question::OsIdentity, root), "running-default-route" => ask(&Question::DefaultRoute, root), + "running-user-email" => ask(&Question::UserEmail, root), "running-os-metadata" => ask(&Question::OsIdentity, root).and_then(|mut all| { all.extend(ask(&Question::DefaultRoute, root)?); Ok(all) @@ -468,7 +531,8 @@ pub(crate) fn resolve( unknown => { return Err(Fatal::new(format!( "{label}: unknown literal source {unknown:?}; known sources are \ - running-os-identity, running-os-metadata, running-default-route, command" + running-os-identity, running-os-metadata, running-default-route, \ + running-user-email, command" ))); } }; diff --git a/src/text.rs b/src/text.rs index d810237..23307b0 100644 --- a/src/text.rs +++ b/src/text.rs @@ -38,6 +38,10 @@ use crate::sources; /// for whether anything already covers it have to mean the same string. const RUNNING_OS_IDENTITY: &str = "running-os-identity"; +/// The built-in literal source that reads the running user's own addresses. +/// Named once for the reason [`RUNNING_OS_IDENTITY`] is. +const RUNNING_USER_EMAIL: &str = "running-user-email"; + /// Used when the caller's repository declares no dynamic rules of its own, or /// has no policy file at all. /// @@ -66,6 +70,41 @@ fn fallback_rule() -> Rule { rule } +/// The hook's second fallback: the running user's own addresses, in a tool +/// call's arguments. +/// +/// At the hook only. A tool call is where an agent hands what it was told +/// about its user to a search engine, a fetched URL or a shell, none of which +/// the user chose to tell. A commit message is not: a `Signed-off-by` trailer +/// at `commit-msg` and a maintainer address a repository publishes on purpose +/// carry the address because the author put it there, and a fallback refusing +/// them would be refusing the author's own decision. A repository that wants +/// the address refused at its other text seams declares a rule reading +/// `running-user-email`, and one that exempts an address -- a published +/// security contact -- shadows this one with `ignore_literals`. +/// +/// Every needle this source yields is withheld, so neither the refusal nor +/// the evidence carries the address. +fn email_fallback_rule() -> Rule { + let mut rule = Rule::synthetic( + "no-running-user-email", + Check::ForbiddenLiterals { + literals: Literals::Named { + forbidden_literals: String::from(RUNNING_USER_EMAIL), + }, + ignore_literals: None, + }, + ); + rule.message = Some(String::from( + "Do not send the running user's email address in a tool call's arguments. The \ + policy checker reads the addresses git and the environment are configured with \ + and searches what the call was about to send; the matched address and the line \ + it was on are withheld here. Leave the address out, or use a placeholder such as \ + person@example.test.", + )); + rule +} + /// The text to judge, or a refusal. /// /// `from_utf8_lossy` stood here, and it is the quiet version of the failure @@ -499,7 +538,7 @@ pub(crate) fn judged( return Ok(()); } match kind { - Judged::Literals => failures_in(root, policy, whole, Some(&subject), ledger), + Judged::Literals => failures_in(seam, root, policy, whole, Some(&subject), ledger), // Each guard asked under the caller's scope: every one at `scan // --text` and `guard --text`, and at the hook the ones its table // stands down. The shim passes its own memo through `Judged::of`. @@ -809,7 +848,13 @@ pub(crate) fn check(found: Option<&(PathBuf, PathBuf)>, source: &str) -> Result< pub(crate) fn load_for(found: Option<&(PathBuf, PathBuf)>) -> Result<(PathBuf, Policy)> { match found { Some((root, policy_path)) => Ok((root.clone(), config::load(root, policy_path)?)), - None => Ok((std::env::current_dir()?, Policy::default())), + None => Ok(( + std::env::current_dir()?, + Policy { + hooks: config::compiled_hooks()?, + ..Policy::default() + }, + )), } } @@ -817,9 +862,10 @@ pub(crate) fn load_for(found: Option<&(PathBuf, PathBuf)>) -> Result<(PathBuf, P /// already-loaded policy, each answering into `ledger` once per needle found /// or once to pass. /// -/// The literal rules a text seam runs: the policy's, and the running-host -/// fallback where none of them reads the running host's identity. -fn literal_rules(policy: &Policy) -> Vec { +/// The literal rules a text seam runs: the policy's, the running-host +/// fallback where none of them reads the running host's identity, and at the +/// hook the running-user fallback where none of them reads the user's address. +fn literal_rules(policy: &Policy, seam: Seam) -> Vec { // The test is for the identity rule itself, not for the CHECK KIND it // happens to use. Asking whether any `forbidden_literals` rule existed made // an unrelated one -- a repository's own list of literals, a command @@ -839,6 +885,13 @@ fn literal_rules(policy: &Policy) -> Vec { { owned.push(fallback_rule()); } + if seam == Seam::Hook + && !owned + .iter() + .any(|rule| rule.forbidden_literals() == Some(RUNNING_USER_EMAIL)) + { + owned.push(email_fallback_rule()); + } owned } @@ -860,7 +913,7 @@ pub(crate) fn set_aside(policy: &Policy, recorded: &str, ledger: &mut Vec false, }); - for rule in literal_rules(policy).iter().chain(asked) { + for rule in literal_rules(policy, Seam::Hook).iter().chain(asked) { ledger.push(Judgment::of(rule, Some(recorded), Answer::OutOfScope)); } } @@ -869,13 +922,14 @@ pub(crate) fn set_aside(policy: &Policy, recorded: &str, ledger: &mut Vec, ledger: &mut Vec, ) -> Result<()> { - let owned = literal_rules(policy); + let owned = literal_rules(policy, seam); for rule in &owned { let needles = asked( @@ -912,7 +966,7 @@ pub(crate) fn failures_in( .iter() .map(|hit| { let line = hit.line.unwrap_or(0); - if policy.redact_matches { + if policy.redact_matches || needle.withheld { format!("line {line}: [REDACTED_MATCH]") } else { format!("line {line}: {}", hit.text) diff --git a/tests/claude_plugin.rs b/tests/claude_plugin.rs index 2657227..08d670b 100644 --- a/tests/claude_plugin.rs +++ b/tests/claude_plugin.rs @@ -22,7 +22,6 @@ use std::path::Path; use serde_json::{Value, json}; const COMMAND: &str = "uphold hook claude-code"; -const PRIVACY_COMMAND: &str = "uphold hook claude-code --privacy"; const BLOCKING_EVENTS: &[&str] = &["PreToolUse", "Stop"]; /// Every way `hooks` departs from the two properties, one line each. @@ -45,7 +44,7 @@ fn refusals(hooks: &Value) -> Vec { .flatten(); for handler in handlers { let command = handler.get("command").and_then(Value::as_str); - if command != Some(COMMAND) && command != Some(PRIVACY_COMMAND) { + if command != Some(COMMAND) { found.push(format!("{event}: runs {command:?}, not {COMMAND:?}")); } if BLOCKING_EVENTS.contains(&event.as_str()) @@ -58,22 +57,24 @@ fn refusals(hooks: &Value) -> Vec { found } +/// The shell and web tools reach the hook, which the compiled-in `[[hook]]` +/// table for them judges by the rules about this machine and its user. #[test] -fn the_privacy_entry_reaches_web_and_shell_tools() { +fn the_shell_and_web_tools_reach_the_hook() { let hooks = shipped(); let groups = hooks["hooks"]["PreToolUse"] .as_array() .expect("pre-tool groups"); - let privacy = groups - .iter() - .find(|group| group["hooks"][0]["command"].as_str() == Some(PRIVACY_COMMAND)) - .expect("the privacy hook is installed"); - let matcher = regex::Regex::new(privacy["matcher"].as_str().expect("a matcher")) - .expect("a valid matcher"); for tool in ["Bash", "WebFetch", "WebSearch"] { - assert!(matcher.is_match(tool), "{tool} is not protected"); + assert!( + groups.iter().any(|group| { + regex::Regex::new(group["matcher"].as_str().expect("a matcher")) + .expect("a valid matcher") + .is_match(tool) + }), + "{tool} does not reach the hook" + ); } - assert!(!matcher.is_match("mcp__github__create_issue")); } fn shipped() -> Value { diff --git a/tests/hook_cli.rs b/tests/hook_cli.rs index fd7d5f4..47425e4 100644 --- a/tests/hook_cli.rs +++ b/tests/hook_cli.rs @@ -77,23 +77,41 @@ fn workspace(name: &str, policy: Option<&str>) -> PathBuf { /// `HOME` is set on every run, never inherited, for the reason `text_cli` gives: /// the identity needles are read from the environment the check runs in, and a -/// test that let the real one through would assert on this machine. +/// test that let the real one through would assert on this machine. The +/// running user's addresses are read the same way, from git's configuration +/// and the mail variables, so git is pointed at no configuration file at all +/// and those variables are taken away. fn hook(root: &Path, harness: &str, stdin: &str) -> Output { - hook_with(root, harness, stdin, &[]) -} - -fn hook_with(root: &Path, harness: &str, stdin: &str, options: &[&str]) -> Output { - let mut child = Command::new(env!("CARGO_BIN_EXE_uphold")) + hook_as(root, harness, stdin, &[], &[]) +} + +/// [`hook`], with `arguments` after the harness's name and `environment` set +/// over the isolated one. +fn hook_as( + root: &Path, + harness: &str, + stdin: &str, + arguments: &[&str], + environment: &[(&str, &str)], +) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_uphold")); + command .args(["hook", harness]) - .args(options) + .args(arguments) .current_dir(root) .env_remove("UPHOLD_ALLOW") + .env_remove("EMAIL") + .env_remove("GIT_AUTHOR_EMAIL") + .env_remove("GIT_COMMITTER_EMAIL") .env("HOME", "/home/example-user") + .env("XDG_CONFIG_HOME", "/home/example-user/.config") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .envs(environment.iter().copied()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); + .stderr(Stdio::piped()); + let mut child = command.spawn().unwrap(); if let Err(error) = child.stdin.as_mut().unwrap().write_all(stdin.as_bytes()) { // The binary may answer and exit before it reads its input; that is its // verdict, not a failure to deliver the event. @@ -102,128 +120,143 @@ fn hook_with(root: &Path, harness: &str, stdin: &str, options: &[&str]) -> Outpu child.wait_with_output().unwrap() } -#[test] -fn privacy_refuses_addresses_without_disclosing_them_or_loading_a_policy() { - // The publication policy is deliberately unreadable as TOML. Privacy - // checks have no repository policy and must not depend on it loading. - let root = workspace("hook-privacy", Some("not a valid policy")); +// -- the running user's address --------------------------------------------- + +/// The address every test below runs as. `example.test` is reserved (RFC 2606 +/// section 2), so no assertion here is about a real person. +const ADDRESS: &str = "owner@example.test"; + +fn denied(output: &Output) -> bool { + serde_json::from_str::(stdout(output).trim()) + .ok() + .and_then(|document| { + document + .pointer("/hookSpecificOutput/permissionDecision") + .and_then(Value::as_str) + .map(|decision| decision == "deny") + }) + .unwrap_or(false) +} + +/// The running user's address, wherever a shell or web call carries it, is +/// refused, and the refusal never repeats it -- not as the value, not as the +/// line it was found on. +/// +/// The address is read from git's configuration and from the variables git +/// takes one from, so each source is driven on its own. The policy is +/// deliberately absent: a session starts where nobody wrote one, and the +/// fallback is what stands there. +#[test] +fn the_running_users_address_is_refused_and_withheld() { + let root = workspace("hook-user-email", None); + let config = root.join("gitconfig"); + std::fs::write(&config, format!("[user]\n\temail = {ADDRESS}\n")).unwrap(); + let config = config.to_str().unwrap().to_owned(); + let sources: [&[(&str, &str)]; 3] = [ + &[("GIT_CONFIG_GLOBAL", config.as_str())], + &[("GIT_AUTHOR_EMAIL", ADDRESS)], + &[("EMAIL", "Owner@Example.Test")], + ]; + let calls = [ + ( + "Bash", + serde_json::json!({"command": format!("curl -A 'agent ({ADDRESS})' https://example.test")}), + ), + ( + "WebFetch", + serde_json::json!({"url": "https://example.test/?contact=owner%40example.test"}), + ), + ( + "WebFetch", + serde_json::json!({"url": "https://example.test/?contact=owner%2540example.test"}), + ), + ("WebSearch", serde_json::json!({"query": ADDRESS})), + ( + "WebFetch", + serde_json::json!({"headers": {"User-Agent": format!("agent ({ADDRESS})")}}), + ), + ( + "mcp__github__create_issue", + serde_json::json!({"owner": "example-org", "repo": "example", "body": ADDRESS}), + ), + ]; for harness in ["claude-code", "codex"] { - for (tool, arguments) in [ - ( - "Bash", - serde_json::json!({"command": "curl -A 'agent (owner@example.test)' https://example.test"}), - ), - ( - "Bash", - serde_json::json!({"command": "python -c 'send(\"owner@example.test\")'"}), - ), - ( - "WebFetch", - serde_json::json!({"url": "https://example.test/?contact=owner%40example.test"}), - ), - ( - "WebFetch", - serde_json::json!({"url": "https://example.test/?contact=owner%2540example.test"}), - ), - ( - "WebSearch", - serde_json::json!({"query": "owner@example.test"}), - ), - ( - "WebFetch", - serde_json::json!({"headers": {"User-Agent": "agent (owner@example.test)"}}), - ), - ( - "WebFetch", - serde_json::json!({"nested": [{"owner@example.test": true}]}), - ), - ( - "Bash", - serde_json::json!({"command": "git config user.email owner@example.test"}), - ), - ] { - let payload = serde_json::json!({ - "hook_event_name": "PreToolUse", - "tool_name": tool, - "tool_input": arguments, - }) - .to_string(); - let output = hook_with(&root, harness, &payload, &["--privacy"]); - assert_eq!(code(&output), 0, "{}", stderr(&output)); - let document: Value = serde_json::from_str(&stdout(&output)).unwrap(); - assert_eq!( - document - .pointer("/hookSpecificOutput/permissionDecision") - .and_then(Value::as_str), - Some("deny") - ); - for stream in [stdout(&output), stderr(&output)] { - assert!(stream.contains("no-email-in-tool-arguments"), "{stream}"); - assert!(!stream.contains("owner"), "{stream}"); - assert!(!stream.contains("example.test"), "{stream}"); - assert!(!stream.contains("curl"), "{stream}"); + for source in sources { + for (tool, input) in &calls { + let output = hook_as(&root, harness, &call(tool, input), &[], source); + assert_eq!(code(&output), 0, "{tool}: {}", stderr(&output)); + assert!(denied(&output), "{tool} {source:?}: {}", stdout(&output)); + for stream in [stdout(&output), stderr(&output)] { + assert!(stream.contains("no-running-user-email"), "{stream}"); + assert!(!stream.to_lowercase().contains("owner"), "{stream}"); + assert!(!stream.contains("curl"), "{stream}"); + } } } } } +/// Email-shaped text that is nobody's address passes: an SSH remote, a +/// package pin, an image digest, and another person's address. A check that +/// refused these before every shell command would be switched off within the +/// day, and the address it exists for would go out with the rest. +/// +/// And a shell call is not a publication: a guard the policy runs at a forge +/// -- here an authorship marker -- is recorded as out of scope rather than +/// asked, so a commit message being drafted in a heredoc is not refused as if +/// it had been posted. #[test] -fn privacy_allows_ordinary_calls_silently_and_refuses_an_unread_subject() { - let root = workspace("hook-privacy-clean", None); - for arguments in [ - serde_json::json!({"command": "curl https://example.test"}), - serde_json::json!({"url": "https://example.test/a%20b", "query": "documentation"}), - serde_json::json!({"count": 3, "enabled": true, "empty": null}), +fn email_shaped_text_and_publication_guards_do_not_stop_a_shell_call() { + let root = workspace("hook-user-email-clean", Some(POLICY)); + let directory = root.join("evidence"); + std::fs::create_dir(&directory).unwrap(); + for command in [ + "git clone git@github.com:example-org/example.git", + "npm install react@18 && npx tool@latest", + "docker pull image@sha256:0123abcd", + "git commit -m 'x' -m 'Co-Authored-By: Claude '", + "printf '%s' someone-else@example.test", ] { - let payload = serde_json::json!({ - "hook_event_name": "PreToolUse", "tool_name": "WebFetch", "tool_input": arguments, - }) - .to_string(); - let output = hook_with(&root, "claude-code", &payload, &["--privacy"]); - assert_eq!(code(&output), 0, "{}", stderr(&output)); - assert_eq!(stdout(&output), ""); - assert_eq!(stderr(&output), ""); + let output = hook_as( + &root, + "claude-code", + &call("Bash", &serde_json::json!({"command": command})), + &["--junit", directory.to_str().unwrap()], + &[("GIT_AUTHOR_EMAIL", ADDRESS)], + ); + assert_eq!(code(&output), 0, "{command}: {}", stderr(&output)); + assert_eq!(stdout(&output), "", "{command}"); } - for payload in [ - "not JSON", - r#"{"hook_event_name":"PreToolUse","tool_name":"WebFetch"}"#, - r#"{"hook_event_name":"Stop","tool_input":{}}"#, - ] { - let output = hook_with(&root, "claude-code", payload, &["--privacy"]); - assert_eq!(code(&output), 2); - assert_ne!(stderr(&output), ""); + for entry in std::fs::read_dir(&directory).unwrap() { + let record = std::fs::read_to_string(entry.unwrap().path()).unwrap(); + assert!(!record.contains(" = std::fs::read_dir(&directory) .unwrap() @@ -238,12 +271,53 @@ fn privacy_records_both_outcomes_without_the_address() { 1 ); for record in records { - assert!(record.contains("no-email-in-tool-arguments")); - assert!(!record.contains("owner")); - assert!(!record.contains("example.test")); + assert!(record.contains("no-running-user-email"), "{record}"); + assert!(!record.contains("owner"), "{record}"); } } +/// The two ends of the policy: a repository that publishes an address on +/// purpose shadows the fallback with `ignore_literals`, and one that wants +/// every address refused, whoever's, writes a pattern rule for the hook. +#[test] +fn a_policy_exempts_an_address_or_refuses_every_one() { + let exempt = workspace( + "hook-user-email-exempt", + Some( + "[rule.no-running-user-email]\nmessage = \"x\"\n\ + forbidden_literals = \"running-user-email\"\n\ + ignore_literals = [\"owner@example.test\"]\nfiles.include = [\".\"]\n", + ), + ); + let every = workspace( + "hook-user-email-every", + Some( + "[rule.no-address-in-tool-calls]\nmessage = \"Leave addresses out.\"\n\ + regexp = '[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}'\n\ + seams = [\"hook\"]\ncommand.before = [\"gh\"]\ncommand.scope = \"always\"\n", + ), + ); + let ask = |root: &Path, query: &str| { + hook_as( + root, + "claude-code", + &call("WebSearch", &serde_json::json!({"query": query})), + &[], + &[("GIT_AUTHOR_EMAIL", ADDRESS)], + ) + }; + let exempted = ask(&exempt, ADDRESS); + assert_eq!(code(&exempted), 0, "{}", stderr(&exempted)); + assert!(!denied(&exempted), "{}", stdout(&exempted)); + + let anyone = ask(&every, "someone-else@example.test"); + assert_eq!(code(&anyone), 0, "{}", stderr(&anyone)); + assert!(denied(&anyone), "{}", stderr(&anyone)); + assert!(stdout(&anyone).contains("no-address-in-tool-calls")); + let pin = ask(&every, "npm install react@18"); + assert!(!denied(&pin), "{}", stdout(&pin)); +} + fn stdout(output: &Output) -> String { String::from_utf8_lossy(&output.stdout).into_owned() } @@ -1168,7 +1242,7 @@ fn every_call_writes_one_document_naming_each_rule_and_never_the_body() { assert!(!text.contains("Generated"), "{text}"); let (second, _) = support::junit::suite(files.last().unwrap()); - assert_eq!((second.tests, second.failures), (3, 0), "{second:?}"); + assert_eq!((second.tests, second.failures), (4, 0), "{second:?}"); } /// The hook runs once per publishing call, so one file would keep only the @@ -2069,12 +2143,13 @@ fn a_skipped_read_writes_a_record_of_rules_set_aside() { "prevent-ai-author", "no-empty-hedge-here", "no-running-os-identity-metadata", + "no-running-user-email", ] { let case = support::junit::case(&suite, id); assert_eq!(support::junit::state(case).0, "skipped", "{id}"); assert_eq!(support::junit::properties(case, "subject"), [tool]); } - assert_eq!((suite.tests, suite.failures), (3, 0), "{suite:?}"); + assert_eq!((suite.tests, suite.failures), (4, 0), "{suite:?}"); } /// A tool call has no remote, so `git-remote` is not a `[[hook]]` target: the From 3f6c03e427ffe474dae9faddf88beaf92ffb9a94 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sun, 8 Nov 2026 15:35:46 +0900 Subject: [PATCH 3/4] Say that shadowing the user-email fallback makes it a scan rule A rule reading `running-user-email` needs `files.*`, as every `forbidden_literals` rule does, and `uphold scan` then searches those files for the address. The example scoped it to the whole tree, which refuses the maintainer fields and `SECURITY.md` contacts that carry the address on purpose. It now scopes to `docs` and says why, and the percent-decoding sentence names the fields it skips. Claude-Session: https://claude.ai/code/session_01EycJ7monkqAU8KKjL7Qp3L --- docs/REFERENCE.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 1fe8664..a772ae1 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -3290,19 +3290,25 @@ which reads `running-user-email`. The second refuses the running user's own address. It does not refuse every email-shaped string: an SSH remote, a package pin and an image digest look like addresses and identify nobody. A refusal and its `--junit` record name the rule and where the address was configured, never -the address or the line it was on. Each string the call carries is also read -through up to four layers of percent-encoding (RFC 3986 section 2.1), so -`owner%40example.test` and `owner%2540example.test` are found. +the address or the line it was on. Each string the call carries, except a +title or commit subject, is also read through up to four layers of +percent-encoding (RFC 3986 section 2.1), so `owner%40example.test` and +`owner%2540example.test` are found. -A repository changes either end in policy: +A repository changes either end in policy. Declaring a rule that reads +`running-user-email` replaces the fallback, and like every `forbidden_literals` +rule it needs `files.*` and is then a content rule `uphold scan` runs over +those files too: scope it to where the address must never appear, since a +maintainer field or a `SECURITY.md` contact carries it on purpose. ```toml -# A published security contact is the user's address on purpose. +# The address in tool calls, in the docs tree, and nowhere else in the tree; +# a shared role address the user also sends as is exempt. [rule.no-running-user-email] message = "Leave the address out." forbidden_literals = "running-user-email" ignore_literals = ["security@example.test"] -files.include = ["."] +files.include = ["docs"] # Every address, whoever's, in a tool call. [rule.no-address-in-tool-calls] From a4e9d4ff4ebcfa46656d8b48d15cbd8d7d6d0ab2 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sun, 8 Nov 2026 15:54:32 +0900 Subject: [PATCH 4/4] Read the user's address without git, and only where it is one Three review findings on the `running-user-email` source. Where git cannot run, `try_run`'s error made the source fail, and every text-bearing shell or web call exited 2: a container without git would have every call blocked. The variables are still read, and stderr says that no git-configured address was searched for, as the default-route source says when `ip` cannot run. A configured value is a needle only where it is shaped like an address, by `is_address`: a local part, one `@`, a dotted domain, no space. `EMAIL=@` was a needle matching `react@18`, refused under a report that withholds what matched. The hook tests set `GIT_CEILING_DIRECTORIES` to the fixture's parent, so a scratch directory under a checkout does not hand that checkout's `user.email` to the calls. And the Kani job runs on ubuntu-latest again. #499 moved it to ubuntu-slim beside a comment that says why it is not there, and on this branch the Kani compiler was killed by signal 9 while building the crate. Claude-Session: https://claude.ai/code/session_01EycJ7monkqAU8KKjL7Qp3L --- .github/workflows/test.yml | 2 +- src/sources.rs | 57 +++++++++++++++++++++++++++++++++++--- tests/hook_cli.rs | 26 +++++++++++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f7a2c3..ec7f09f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -215,7 +215,7 @@ jobs: needs: dedupe if: needs.dedupe.outputs.passed != 'true' name: kani proofs - runs-on: ubuntu-slim + runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: diff --git a/src/sources.rs b/src/sources.rs index ad79bde..5a43069 100644 --- a/src/sources.rs +++ b/src/sources.rs @@ -361,9 +361,21 @@ fn running_default_route() -> Vec { /// no needles, which is an answer -- nothing here is known to be anyone's -- /// and git failing to run at all is the could-not-look it is. fn running_user_email(root: &Path) -> Answer { - let configured = crate::git::try_run(root, &["config", "--get-all", "user.email"]) - .map_err(|error| error.to_string())? - .unwrap_or_default(); + // A machine with no git is an ordinary container, and a source that made + // every shell call there exit 2 would get the matcher deleted. The + // variables are still read, and stderr says what was not, as the + // default-route source does when `ip` cannot run. + let configured = match crate::git::try_run(root, &["config", "--get-all", "user.email"]) { + Ok(answer) => answer.unwrap_or_default(), + Err(error) => { + eprintln!( + "uphold: {error}, so the running-user-email source read only \ + GIT_AUTHOR_EMAIL, GIT_COMMITTER_EMAIL and EMAIL, and no address git is \ + configured with was searched for." + ); + String::new() + } + }; let mut needles: Vec = Vec::new(); let mut seen = BTreeSet::new(); let found = configured @@ -376,7 +388,7 @@ fn running_user_email(root: &Path) -> Answer { ); for (from, value) in found { let value = value.trim(); - if !value.contains('@') { + if !is_address(value) { continue; } for spelling in [value.to_owned(), value.to_lowercase()] { @@ -393,6 +405,23 @@ fn running_user_email(root: &Path) -> Answer { Ok(needles) } +/// Whether a configured value is an address worth searching for: one `@` +/// with a local part before it and a dotted domain after it (RFC 5321 section +/// 4.1.2's `Mailbox`, loosely), and no space. `EMAIL=@` or a bare name is a +/// misconfiguration, and as a needle it would refuse `react@18` under a +/// report that withholds what matched. +fn is_address(value: &str) -> bool { + let Some((local, domain)) = value.split_once('@') else { + return false; + }; + !local.is_empty() + && !domain.contains('@') + && !value.contains(char::is_whitespace) + && domain.split('.').filter(|label| !label.is_empty()).count() >= 2 + && !domain.starts_with('.') + && !domain.ends_with('.') +} + /// Run a command and read one needle per line of its stdout. /// /// This is what replaced `policy/sources.py`. A repository that needed a custom @@ -553,6 +582,26 @@ pub(crate) fn resolve( mod tests { use super::*; + #[test] + fn only_a_value_shaped_like_an_address_is_searched_for() { + for value in ["owner@example.test", "o.w+n@sub.example.test"] { + assert!(is_address(value), "{value}"); + } + for value in [ + "@", + "owner", + "owner@", + "@example.test", + "react@18", + "a@b@c.d", + "o w@x.y", + "o@.x", + "o@x.", + ] { + assert!(!is_address(value), "{value}"); + } + } + #[test] fn an_address_says_which_machine_unless_it_is_loopback_link_local_or_multicast() { for address in ["192.168.1.20", "10.0.0.5", "8.8.8.8"] { diff --git a/tests/hook_cli.rs b/tests/hook_cli.rs index 47425e4..3894709 100644 --- a/tests/hook_cli.rs +++ b/tests/hook_cli.rs @@ -107,6 +107,9 @@ fn hook_as( .env("XDG_CONFIG_HOME", "/home/example-user/.config") .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") + // And no repository above the fixture: a scratch directory under a + // checkout would hand its `user.email` to every call here. + .env("GIT_CEILING_DIRECTORIES", root.parent().unwrap()) .envs(environment.iter().copied()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -235,6 +238,29 @@ fn email_shaped_text_and_publication_guards_do_not_stop_a_shell_call() { } } +/// Where git cannot run, the variables still name the address, and a call +/// without it passes rather than exiting 2: a container with no git is +/// ordinary, and a hook that blocked every shell call there would be removed. +#[test] +fn without_git_the_variables_are_still_searched() { + let root = workspace("hook-user-email-no-git", None); + let empty = root.join("bin"); + std::fs::create_dir(&empty).unwrap(); + let path = empty.to_str().unwrap(); + for (query, refused) in [(ADDRESS, true), ("ordinary request", false)] { + let output = hook_as( + &root, + "claude-code", + &call("WebSearch", &serde_json::json!({"query": query})), + &[], + &[("PATH", path), ("GIT_AUTHOR_EMAIL", ADDRESS)], + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert_eq!(denied(&output), refused, "{query}: {}", stdout(&output)); + assert!(stderr(&output).contains("running-user-email source read only")); + } +} + /// The evidence a refused call leaves names the rule and withholds the /// address, as the refusal does. #[test]