From 2df71a92388b507a6fb6090a65451aef895b88ef Mon Sep 17 00:00:00 2001 From: Minh_Nguyen Date: Thu, 9 Jul 2026 09:07:22 +0700 Subject: [PATCH 01/14] feat: add mailchimp api key scanning pattern and tests (#190) --- node/src/scanners/secret-patterns.ts | 8 ++++++++ node/tests/secret-patterns.test.ts | 8 ++++++++ python/rafter_cli/scanners/secret_patterns.py | 7 +++++++ python/tests/test_regex_scanner.py | 5 +++++ 4 files changed, 28 insertions(+) diff --git a/node/src/scanners/secret-patterns.ts b/node/src/scanners/secret-patterns.ts index d61219b..1d56d78 100644 --- a/node/src/scanners/secret-patterns.ts +++ b/node/src/scanners/secret-patterns.ts @@ -167,6 +167,14 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ regex: "dop_v1_[a-f0-9]{64}", severity: "critical", description: "DigitalOcean Personal Access Token detected" + }, + + // Mailchimp + { + name: "Mailchimp API Key", + regex: "[a-f0-9]{32}-us\\d{1,2}", + severity: "critical", + description: "Mailchimp API Key detected" } ]; diff --git a/node/tests/secret-patterns.test.ts b/node/tests/secret-patterns.test.ts index d6c17cb..1f2ff86 100644 --- a/node/tests/secret-patterns.test.ts +++ b/node/tests/secret-patterns.test.ts @@ -318,6 +318,14 @@ describe("Secret patterns — coverage matrix", () => { }); }); + describe("Mailchimp API Key", () => { + it("detects mailchimp key", () => { + const token = "a".repeat(32) + "-us12"; + const r = scanString(token + "\n"); + expect(r.matches.some((m) => m.pattern.name.includes("Mailchimp"))).toBe(true); + }); + }); + // ── Helper function coverage ────────────────────────────────────── describe("getPatternsBySeverity", () => { diff --git a/python/rafter_cli/scanners/secret_patterns.py b/python/rafter_cli/scanners/secret_patterns.py index 06b3e10..51f4786 100644 --- a/python/rafter_cli/scanners/secret_patterns.py +++ b/python/rafter_cli/scanners/secret_patterns.py @@ -155,6 +155,13 @@ severity="critical", description="DigitalOcean Personal Access Token detected", ), + # Mailchimp + Pattern( + name="Mailchimp API Key", + regex=r"[a-f0-9]{32}-us\d{1,2}", + severity="critical", + description="Mailchimp API Key detected", + ) ] diff --git a/python/tests/test_regex_scanner.py b/python/tests/test_regex_scanner.py index cdffbd4..287eb47 100644 --- a/python/tests/test_regex_scanner.py +++ b/python/tests/test_regex_scanner.py @@ -80,6 +80,11 @@ def test_scan_text_detects_digitalocean_token(scanner): matches = scanner.scan_text(token) assert any(m.pattern.name == "DigitalOcean Personal Access Token" for m in matches) +def test_scan_text_detects_mailchimp_key(scanner): + token = ("a" * 32) + "-us12" + matches = scanner.scan_text(token) + assert any(m.pattern.name == "Mailchimp API Key" for m in matches) + def test_has_secrets(scanner): assert scanner.has_secrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij") assert not scanner.has_secrets("just a normal string") From 87d39e78feac0a6995430a1ca63b4e14b0ceb150 Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:07:26 -0700 Subject: [PATCH 02/14] fix(audit): redact secret-named env-assignment prefixes before logging (ob-y5ep) (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit logger wrote command lines through `scanner.redact()`, which only masked substrings matching a known secret *pattern*. A command run as `RAFTER_API_KEY= rafter ...` logged the value in plaintext: the key's shape matches no built-in pattern, so `redact()` left it untouched — leaking real API keys into `~/.rafter/audit.jsonl` despite `redact_secrets: true`. Add env-assignment redaction to the shared redaction path (`PatternEngine.redactText` / `redact_text`), so every `scanner.redact(command)` call site benefits (both `logCommandIntercepted` and `logPolicyOverride`). Any `NAME=VALUE` token whose NAME looks secret-bearing (`/(?:^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|PWD|API[_-]?KEY|ACCESS[_-]?KEY|CREDENTIALS?|AUTH)$/i`, which includes RAFTER_API_KEY) has its VALUE masked with the codebase's existing char-mask. This is additive to the existing pattern-based redaction — it catches values that match no pattern but sit behind a secret-named env var. Benign assignments like `FOO=bar` and `NODE_ENV=production` are left untouched. Both implementations updated in lockstep (identical regex, ordering, and mask). Tests added in both suites: a `RAFTER_API_KEY= rafter scan .` command must not leak the raw value into the log, and benign assignments must be preserved. Co-authored-by: Claude Opus 4.8 --- node/src/core/pattern-engine.ts | 40 ++++++++++++++++++- node/tests/audit-logger.test.ts | 24 ++++++++++++ node/tests/pattern-engine.test.ts | 32 +++++++++++++++ python/rafter_cli/core/pattern_engine.py | 43 ++++++++++++++++++++- python/tests/test_audit_logger_redaction.py | 31 +++++++++++++++ python/tests/test_pattern_engine.py | 30 ++++++++++++++ 6 files changed, 196 insertions(+), 4 deletions(-) diff --git a/node/src/core/pattern-engine.ts b/node/src/core/pattern-engine.ts index 10da9e2..17e4235 100644 --- a/node/src/core/pattern-engine.ts +++ b/node/src/core/pattern-engine.ts @@ -24,6 +24,24 @@ const VARIABLE_NAME_RE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/; const LOWERCASE_IDENT_RE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/; const QUOTED_VALUE_RE = /['"]([^'"]+)['"]/; +/** + * Matches an environment-assignment prefix in a command line: an identifier + * followed immediately by `=` and a run of non-whitespace (the value). Used to + * find `NAME=VALUE` tokens like `RAFTER_API_KEY= rafter ...` so their + * value can be redacted before the command is written to the audit log. + */ +const ENV_ASSIGN_RE = /(^|\s)([A-Za-z_][A-Za-z0-9_]*)=(\S+)/g; + +/** + * A `NAME` in a `NAME=VALUE` assignment is treated as secret-bearing when it + * ends in a credential-suggesting word (e.g. RAFTER_API_KEY, GITHUB_TOKEN, + * DB_PASSWORD, AUTH). Values behind such names are redacted even when they + * don't match any known secret pattern (that's the whole point — the value of + * a bespoke API key won't match a built-in pattern, but it's still a secret). + * Plain names like FOO or NODE_ENV do not match, so `FOO=bar` is left intact. + */ +const SECRET_ENV_NAME_RE = /(?:^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|PWD|API[_-]?KEY|ACCESS[_-]?KEY|CREDENTIALS?|AUTH)$/i; + export class PatternEngine { private patterns: Pattern[]; @@ -85,10 +103,18 @@ export class PatternEngine { } /** - * Redact text by replacing sensitive patterns + * Redact text by replacing sensitive patterns. + * + * Two layers, both additive: + * 1. Env-assignment redaction: any `NAME=VALUE` whose NAME looks + * secret-bearing has its VALUE masked, even if the VALUE matches no + * known pattern. This catches leaks like `RAFTER_API_KEY= rafter …` + * in a logged command line, where the key's shape is unknown. + * 2. Pattern-based redaction: values that match a built-in secret pattern + * are masked wherever they appear. */ redactText(text: string): string { - let redacted = text; + let redacted = this.redactEnvAssignments(text); for (const pattern of this.patterns) { const regex = this.createRegex(pattern.regex); @@ -100,6 +126,16 @@ export class PatternEngine { return redacted; } + /** + * Mask the VALUE of every `NAME=VALUE` token whose NAME looks + * secret-bearing. Non-secret names (FOO, NODE_ENV, …) are left untouched. + */ + private redactEnvAssignments(text: string): string { + return text.replace(ENV_ASSIGN_RE, (full, prefix: string, name: string, value: string) => + SECRET_ENV_NAME_RE.test(name) ? `${prefix}${name}=${this.redact(value)}` : full + ); + } + /** * Check if text contains any sensitive patterns */ diff --git a/node/tests/audit-logger.test.ts b/node/tests/audit-logger.test.ts index 1d6893e..2b24c94 100644 --- a/node/tests/audit-logger.test.ts +++ b/node/tests/audit-logger.test.ts @@ -141,6 +141,30 @@ describe("AuditLogger", () => { expect(onDisk).not.toContain(token); }); + // ob-y5ep: a RAFTER_API_KEY env-assignment prefix leaked verbatim because + // the key's value matches no built-in secret pattern. Redaction must key + // off the secret-named env var, not just the value's shape. + it("logCommandIntercepted redacts a RAFTER_API_KEY env-assignment prefix", () => { + const logger = new AuditLogger(logPath); + const key = "sk-deadbeefdeadbeef"; + logger.logCommandIntercepted(`RAFTER_API_KEY=${key} rafter scan .`, true, "allowed"); + const entries = logger.read(); + const logged = entries[0].action?.command as string; + expect(logged).not.toContain(key); + expect(logged).toContain("RAFTER_API_KEY="); + expect(logged).toMatch(/\*/); + // and never on disk + expect(fs.readFileSync(logPath, "utf-8")).not.toContain(key); + }); + + it("logCommandIntercepted preserves benign env assignments", () => { + const logger = new AuditLogger(logPath); + logger.logCommandIntercepted("FOO=bar NODE_ENV=production rafter scan .", true, "allowed"); + const logged = logger.read()[0].action?.command as string; + expect(logged).toContain("FOO=bar"); + expect(logged).toContain("NODE_ENV=production"); + }); + it("auto-populates cwd and gitRepo on every entry", () => { const logger = new AuditLogger(logPath); logger.logCommandIntercepted("ls", true, "allowed"); diff --git a/node/tests/pattern-engine.test.ts b/node/tests/pattern-engine.test.ts index 8ed958e..d4dc61c 100644 --- a/node/tests/pattern-engine.test.ts +++ b/node/tests/pattern-engine.test.ts @@ -240,4 +240,36 @@ line 3`; expect(critical.length).toBe(2); expect(critical.every(p => p.severity === "critical")).toBe(true); }); + + // ob-y5ep: env-assignment redaction. A value whose NAME looks secret-bearing + // must be masked even when the value matches no known secret pattern. + describe("env-assignment redaction (ob-y5ep)", () => { + const engine = new PatternEngine(testPatterns); + + it("redacts a RAFTER_API_KEY value that matches no pattern", () => { + const out = engine.redactText("RAFTER_API_KEY=sk-deadbeefdeadbeef rafter scan ."); + expect(out).not.toContain("sk-deadbeefdeadbeef"); + expect(out).toContain("RAFTER_API_KEY="); + expect(out).toContain("rafter scan ."); + }); + + it("redacts values behind common secret-named env vars", () => { + for (const name of ["API_KEY", "GITHUB_TOKEN", "DB_PASSWORD", "AWS_SECRET", "AUTH", "PASSWD"]) { + const out = engine.redactText(`${name}=supersecretvalue123 next`); + expect(out, name).not.toContain("supersecretvalue123"); + } + }); + + it("leaves benign assignments untouched", () => { + expect(engine.redactText("FOO=bar")).toBe("FOO=bar"); + expect(engine.redactText("NODE_ENV=production")).toBe("NODE_ENV=production"); + expect(engine.redactText("PATH=/usr/bin rafter scan .")).toBe("PATH=/usr/bin rafter scan ."); + }); + + it("redacts only the secret assignment in a mixed command", () => { + const out = engine.redactText("FOO=bar SECRET_TOKEN=abcdef1234567890 rafter"); + expect(out).toContain("FOO=bar"); + expect(out).not.toContain("abcdef1234567890"); + }); + }); }); diff --git a/python/rafter_cli/core/pattern_engine.py b/python/rafter_cli/core/pattern_engine.py index 840bbf9..9e5bf5d 100644 --- a/python/rafter_cli/core/pattern_engine.py +++ b/python/rafter_cli/core/pattern_engine.py @@ -33,6 +33,23 @@ class PatternMatch: _LOWERCASE_IDENT_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$") _QUOTED_VALUE_RE = re.compile(r"""['\"]([^'\"]+)['\"]""") +# Matches an environment-assignment prefix in a command line: an identifier +# followed immediately by ``=`` and a run of non-whitespace (the value). Used +# to find ``NAME=VALUE`` tokens like ``RAFTER_API_KEY= rafter ...`` so +# their value can be redacted before the command is written to the audit log. +_ENV_ASSIGN_RE = re.compile(r"(^|\s)([A-Za-z_][A-Za-z0-9_]*)=(\S+)") + +# A ``NAME`` in a ``NAME=VALUE`` assignment is treated as secret-bearing when +# it ends in a credential-suggesting word (RAFTER_API_KEY, GITHUB_TOKEN, +# DB_PASSWORD, AUTH, ...). Values behind such names are redacted even when they +# don't match any known secret pattern -- the value of a bespoke API key won't +# match a built-in pattern, but it's still a secret. Plain names like FOO or +# NODE_ENV do not match, so ``FOO=bar`` is left intact. +_SECRET_ENV_NAME_RE = re.compile( + r"(?:^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|PWD|API[_-]?KEY|ACCESS[_-]?KEY|CREDENTIALS?|AUTH)$", + re.IGNORECASE, +) + class PatternEngine: def __init__(self, patterns: Sequence[Pattern]): @@ -76,8 +93,18 @@ def scan_with_position(self, text: str) -> list[PatternMatch]: return matches def redact_text(self, text: str) -> str: - """Replace all pattern matches in *text* with redacted versions.""" - result = text + """Replace all pattern matches in *text* with redacted versions. + + Two layers, both additive: + 1. Env-assignment redaction: any ``NAME=VALUE`` whose NAME looks + secret-bearing has its VALUE masked, even if the VALUE matches no + known pattern. This catches leaks like + ``RAFTER_API_KEY= rafter ...`` in a logged command line, + where the key's shape is unknown. + 2. Pattern-based redaction: values matching a built-in secret + pattern are masked wherever they appear. + """ + result = self._redact_env_assignments(text) for pattern in self._patterns: compiled = self._compile(pattern.regex) if compiled is None: @@ -88,6 +115,18 @@ def redact_text(self, text: str) -> str: ) return result + @staticmethod + def _redact_env_assignments(text: str) -> str: + """Mask the VALUE of every ``NAME=VALUE`` token whose NAME looks + secret-bearing. Non-secret names (FOO, NODE_ENV, ...) are untouched.""" + def _sub(m: re.Match) -> str: + prefix, name, value = m.group(1), m.group(2), m.group(3) + if _SECRET_ENV_NAME_RE.search(name): + return f"{prefix}{name}={PatternEngine._redact(value)}" + return m.group(0) + + return _ENV_ASSIGN_RE.sub(_sub, text) + def has_matches(self, text: str) -> bool: return len(self.scan(text)) > 0 diff --git a/python/tests/test_audit_logger_redaction.py b/python/tests/test_audit_logger_redaction.py index 8a4cbe8..28d380a 100644 --- a/python/tests/test_audit_logger_redaction.py +++ b/python/tests/test_audit_logger_redaction.py @@ -55,6 +55,37 @@ def test_log_command_intercepted_redacts_github_token(audit_path, enabled_config assert entry["eventType"] == "command_intercepted" +def test_log_command_intercepted_redacts_rafter_api_key_env_prefix(audit_path, enabled_config): + """ob-y5ep: a RAFTER_API_KEY= env prefix leaked verbatim because the + key's value matches no built-in pattern. It must be redacted anyway.""" + logger = AuditLogger(log_path=audit_path) + key = "sk-deadbeefdeadbeef" + logger.log_command_intercepted( + f"RAFTER_API_KEY={key} rafter scan .", + passed=True, + action_taken="allowed", + ) + on_disk = audit_path.read_text() + assert key not in on_disk, "raw RAFTER_API_KEY leaked into audit.jsonl" + entry = json.loads(on_disk.strip()) + assert "RAFTER_API_KEY=" in entry["action"]["command"] + assert "*" in entry["action"]["command"] + + +def test_log_command_intercepted_preserves_benign_env_assignments(audit_path, enabled_config): + """ob-y5ep: non-secret env assignments must survive redaction intact.""" + logger = AuditLogger(log_path=audit_path) + logger.log_command_intercepted( + "FOO=bar NODE_ENV=production rafter scan .", + passed=True, + action_taken="allowed", + ) + entry = json.loads(audit_path.read_text().strip()) + command = entry["action"]["command"] + assert "FOO=bar" in command + assert "NODE_ENV=production" in command + + def test_log_command_intercepted_preserves_risk_assessment(audit_path, enabled_config): logger = AuditLogger(log_path=audit_path) logger.log_command_intercepted("rm -rf /", passed=False, action_taken="blocked") diff --git a/python/tests/test_pattern_engine.py b/python/tests/test_pattern_engine.py index 273db1d..a325347 100644 --- a/python/tests/test_pattern_engine.py +++ b/python/tests/test_pattern_engine.py @@ -299,3 +299,33 @@ def test_all_patterns_present(): assert len(DEFAULT_SECRET_PATTERNS) >= 22 names = [p.name for p in DEFAULT_SECRET_PATTERNS] assert len(names) == len(set(names)), "pattern names must be unique" + + +# -- Env-assignment redaction (ob-y5ep) -------------------------------------- +# A value whose NAME looks secret-bearing must be masked even when the value +# matches no known secret pattern (e.g. a bespoke RAFTER_API_KEY). + +def test_env_redaction_masks_rafter_api_key_value(): + out = _engine().redact_text("RAFTER_API_KEY=sk-deadbeefdeadbeef rafter scan .") + assert "sk-deadbeefdeadbeef" not in out + assert "RAFTER_API_KEY=" in out + assert "rafter scan ." in out + + +def test_env_redaction_masks_common_secret_named_vars(): + for name in ("API_KEY", "GITHUB_TOKEN", "DB_PASSWORD", "AWS_SECRET", "AUTH", "PASSWD"): + out = _engine().redact_text(f"{name}=supersecretvalue123 next") + assert "supersecretvalue123" not in out, name + + +def test_env_redaction_preserves_benign_assignments(): + engine = _engine() + assert engine.redact_text("FOO=bar") == "FOO=bar" + assert engine.redact_text("NODE_ENV=production") == "NODE_ENV=production" + assert engine.redact_text("PATH=/usr/bin rafter scan .") == "PATH=/usr/bin rafter scan ." + + +def test_env_redaction_only_touches_secret_assignment_in_mixed_command(): + out = _engine().redact_text("FOO=bar SECRET_TOKEN=abcdef1234567890 rafter") + assert "FOO=bar" in out + assert "abcdef1234567890" not in out From f600798c49a37b2db23ac0fe7c6c0b6f601d2ecf Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:10:11 -0700 Subject: [PATCH 03/14] test(scan): add Mailchimp negative-case tests + tidy whitespace (follow-up to #190) (#194) Follow-up polish on @Minh-Nguyen-2k7's Mailchimp pattern (#190): asserts a bare 32-char hex with no -us datacenter suffix does NOT match, in both Node and Python, and removes trailing whitespace on the Python test. Test-only; pattern unchanged. Verified: node secret-patterns 58 passed, python test_regex_scanner 16 passed. Co-authored-by: Minh-Nguyen-2k7 Co-authored-by: Claude Opus 4.8 --- node/tests/secret-patterns.test.ts | 6 ++++++ python/tests/test_regex_scanner.py | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/node/tests/secret-patterns.test.ts b/node/tests/secret-patterns.test.ts index 1f2ff86..10e00ad 100644 --- a/node/tests/secret-patterns.test.ts +++ b/node/tests/secret-patterns.test.ts @@ -324,6 +324,12 @@ describe("Secret patterns — coverage matrix", () => { const r = scanString(token + "\n"); expect(r.matches.some((m) => m.pattern.name.includes("Mailchimp"))).toBe(true); }); + + it("does not match a bare 32-char hex string without the -us datacenter suffix", () => { + const notAKey = "a".repeat(32); + const r = scanString(notAKey + "\n"); + expect(r.matches.some((m) => m.pattern.name.includes("Mailchimp"))).toBe(false); + }); }); // ── Helper function coverage ────────────────────────────────────── diff --git a/python/tests/test_regex_scanner.py b/python/tests/test_regex_scanner.py index 287eb47..9b5d608 100644 --- a/python/tests/test_regex_scanner.py +++ b/python/tests/test_regex_scanner.py @@ -84,7 +84,12 @@ def test_scan_text_detects_mailchimp_key(scanner): token = ("a" * 32) + "-us12" matches = scanner.scan_text(token) assert any(m.pattern.name == "Mailchimp API Key" for m in matches) - + +def test_scan_text_ignores_bare_hex_without_datacenter_suffix(scanner): + token = "a" * 32 + matches = scanner.scan_text(token) + assert not any(m.pattern.name == "Mailchimp API Key" for m in matches) + def test_has_secrets(scanner): assert scanner.has_secrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij") assert not scanner.has_secrets("just a normal string") From d7317a93bdd1ca233e362a2feb302bff44317199 Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:43:23 -0700 Subject: [PATCH 04/14] docs(skills): scope rafter security skills by surface, not task label (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rafter skill prompts were all-escalation with no scoping ("DO NOT stop at local", "an un-evaluated 'done' is not done", "default: run rafter run") and never said WHAT counts as security-relevant or when the gate does NOT apply. Agents writing research/experimental code with zero security surface (training scripts, data analysis, plotting, model eval, notebooks, pure computation over trusted local data) kept getting the gate in the way. Fix (prompting only — no executable code changes): add a "When this applies (and when it doesn't)" framing driven by the security SURFACE of the diff, not the task's label. - ENGAGE fully (unchanged, not weakened): auth, credentials/secrets/ tokens/sessions, user/untrusted input, SQL, shell/exec, file paths + uploads, deserialization, crypto, network-facing endpoints, data deletion, dependency/manifest changes. - BACK OFF: research/experimental/local-only/throwaway code with NONE of that surface — a quick surface check suffices, and with genuinely no surface it is fine to proceed without the full rafter-code-review + rafter run. - Key rule landed in every edited skill: judge by the actual security surface of the diff, not by whether the work is called "research." Research code that touches secrets/shell/network/untrusted input still fully engages. Also surface-conditioned the overly-broad `description:` front-matter (that string drives auto-invocation). Files: node/resources/skills/{rafter,rafter-code-review, rafter-secure-design}/SKILL.md and their byte-identical python mirrors under python/rafter_cli/resources/skills/. rafter-skill-review is left unchanged on purpose: vetting third-party executable context before install is always a genuine security surface, so a back-off there would weaken a real gate. This REDUCES false engagement on no-surface code without removing or softening protection for any genuine security surface. Refs: sable-hjd3 AI-assisted (per repo AI Policy): authored with Claude Opus 4.8. Co-authored-by: Claude Opus 4.8 --- .../skills/rafter-code-review/SKILL.md | 8 +++++- .../skills/rafter-secure-design/SKILL.md | 8 +++++- node/resources/skills/rafter/SKILL.md | 26 ++++++++++++++++++- .../skills/rafter-code-review/SKILL.md | 8 +++++- .../skills/rafter-secure-design/SKILL.md | 8 +++++- .../resources/skills/rafter/SKILL.md | 26 ++++++++++++++++++- 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/node/resources/skills/rafter-code-review/SKILL.md b/node/resources/skills/rafter-code-review/SKILL.md index ef38c22..870f983 100644 --- a/node/resources/skills/rafter-code-review/SKILL.md +++ b/node/resources/skills/rafter-code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: rafter-code-review -description: "REQUIRED before declaring a task done when the diff touches user input, SQL, shell, auth, credentials, file paths, serialization, or dependency surface. Walks OWASP/ASVS/MITRE questions the compiler and the test suite won't catch. Pair with `rafter run` on the same diff. A security-relevant task marked complete without both has been under-delivered — do not claim done." +description: "REQUIRED before declaring a task done when the diff touches user input, SQL, shell, auth, credentials, file paths, serialization, crypto, network endpoints, data deletion, or dependency surface. Judge by that surface, not the task label — research/experimental/local-only code with none of it can skip this. Walks OWASP/ASVS/MITRE questions the compiler and the test suite won't catch. Pair with `rafter run` on the same diff. When the diff has that surface, marking it complete without both has been under-delivered — do not claim done." version: 0.7.0 allowed-tools: [Bash, Read, Glob, Grep] --- @@ -11,6 +11,12 @@ A reviewer's skill, not an audit generator. Each sub-doc is a set of **questions > Pair with the `rafter` skill (detection: `rafter scan`, `rafter run`) and `rafter-secure-design` (prevention: design-phase walks). This skill is the middle stage — review before merge. +## When this applies (and when it doesn't) + +Scoped to the **security surface of the diff**, not the task's label. Walk it fully when the change touches: user / untrusted input, SQL or query building, shell / `exec` / subprocess, auth or access control, credentials / secrets / tokens, file paths or uploads, (de)serialization, crypto, network-facing endpoints or outbound fetchers, data deletion, or dependency / manifest changes. + +If **none** of those are present — research / experimental / exploratory / local-only / throwaway code such as training scripts, data analysis, plotting, model eval, notebooks, or pure computation over trusted local data — a quick surface check is enough; you don't need to walk the full review or pair `rafter run`. But the check is the surface, not the label: research code that reads a secret, shells out, hits the network, or parses untrusted bytes is back on the engage list and gets the full walk. + ## How to use this skill 1. Identify the category of code in front of you (below). diff --git a/node/resources/skills/rafter-secure-design/SKILL.md b/node/resources/skills/rafter-secure-design/SKILL.md index aebadfa..d25e96d 100644 --- a/node/resources/skills/rafter-secure-design/SKILL.md +++ b/node/resources/skills/rafter-secure-design/SKILL.md @@ -1,6 +1,6 @@ --- name: rafter-secure-design -description: "REQUIRED before writing code for any feature touching auth, payments, credentials, tokens, sessions, file upload, user data, or data deletion. Invoke it, record answers inline in the design doc or PR description, then write the code. Skipping this is how auth bypass, token leakage, and delete-the-wrong-record bugs ship. If the feature touches any of those surfaces and this hasn't run, the design is incomplete — do not proceed to implementation." +description: "REQUIRED before writing code for any feature touching auth, payments, credentials, tokens, sessions, file upload, user data, untrusted input, deserialization, network endpoints, or data deletion. Scope by that surface, not the task label — a research/experimental/local-only feature with none of it doesn't need this. Invoke it, record answers inline in the design doc or PR description, then write the code. Skipping this is how auth bypass, token leakage, and delete-the-wrong-record bugs ship. If the feature touches any of those surfaces and this hasn't run, the design is incomplete — do not proceed to implementation." version: 0.1.0 allowed-tools: [Read, Glob, Grep] --- @@ -11,6 +11,12 @@ A designer's skill, not a scanner. The goal is to catch the flaw in the whiteboa > Pair with `rafter-code-review` (structured review *during* PR) and the `rafter` skill (automated detection of what slipped through). This skill is the earliest stage — prevention before the code exists. +## When this applies (and when it doesn't) + +Engage this design walk when the feature you're about to build touches a real security surface: auth or access control, credentials / secrets / tokens / sessions, user or untrusted input, SQL or query construction, shell / `exec`, file paths or uploads, (de)serialization, crypto, network-facing endpoints or outbound fetchers, data deletion, or new dependencies. + +If the thing you're designing has **none** of that — a research / experimental / local-only / throwaway piece such as a training script, analysis pipeline, plot, model-eval harness, notebook, or pure computation over trusted local data — a quick surface check is enough and you can proceed to implementation without the full walk. Judge by the surface, not by whether the work is called "research": a research feature that stores user data, handles a token, or opens a network endpoint is back on the engage list. + ## How to use this skill 1. Identify what's being designed (below). If multiple apply, walk them in the order listed — `threat-modeling` last, as a capstone. diff --git a/node/resources/skills/rafter/SKILL.md b/node/resources/skills/rafter/SKILL.md index 8931326..4e9f5bc 100644 --- a/node/resources/skills/rafter/SKILL.md +++ b/node/resources/skills/rafter/SKILL.md @@ -1,12 +1,36 @@ --- name: rafter -description: "Entry point for rafter. Invoke when a sub-skill is unclear, or when the task needs `rafter run` (remote SAST+SCA), `rafter secrets` (local secrets-only), `rafter audit`, policy checks, or command-risk evaluation. If a task is security-relevant and no rafter skill or CLI call has been made, invoke this before handing the task off — an un-evaluated \"done\" on security-relevant work is not done." +description: "Entry point for rafter. Invoke when a sub-skill is unclear, or when the task needs `rafter run` (remote SAST+SCA), `rafter secrets` (local secrets-only), `rafter audit`, policy checks, or command-risk evaluation. Scope by security surface, not task label: engage when the diff touches auth, credentials/secrets/tokens, untrusted input, SQL, shell/exec, file paths, deserialization, crypto, network endpoints, data deletion, or dependencies; for research/experimental/local-only code with none of that, a quick surface check is enough. When such surface IS present and no rafter skill or CLI call has been made, invoke this before handing the task off — an un-evaluated \"done\" on genuine security surface is not done." version: 0.7.0 allowed-tools: [Bash, Read] --- # Rafter — Security Toolkit for AI Workflows +## When Rafter applies (and when it doesn't) + +Rafter is a **surface-driven** gate, not a task-label gate. Before you engage — or decide not to — do a 10-second read of the diff's actual security surface. Let that answer, not whether the work is called "research", pick the branch. + +**Engage fully** (walk the review + run `rafter run`) when the change touches any of: + +- auth, sessions, or access control +- credentials, secrets, tokens, or keys +- user-supplied or otherwise untrusted input +- SQL or any other query / command construction +- shell, `exec`, or subprocess invocation +- file paths — reads, writes, uploads, traversal +- deserialization or parsing of untrusted data +- crypto (choosing or using primitives) +- network-facing endpoints or outbound fetchers (SSRF surface) +- data deletion or other destructive mutations +- dependency, lockfile, or manifest changes + +**Back off** when the change has **none** of the above — research / experimental / exploratory / local-only / throwaway code: training scripts, data analysis, plotting, model eval, notebooks, pure computation over trusted local data. A quick surface check is enough, and if there is genuinely no security surface it is fine to proceed **without** the full `rafter-code-review` + `rafter run`. + +**The rule that decides it:** the "research" label buys nothing. Research code that reads a secret, shells out, hits the network, parses untrusted input, or bumps a dependency is on the engage list and gets the full gate. Judge by the surface of the diff, not by what the task is called. + +--- + ## Picking the right tier — DO NOT stop at "local" Rafter ships three tiers. **They are not interchangeable.** The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. diff --git a/python/rafter_cli/resources/skills/rafter-code-review/SKILL.md b/python/rafter_cli/resources/skills/rafter-code-review/SKILL.md index ef38c22..870f983 100644 --- a/python/rafter_cli/resources/skills/rafter-code-review/SKILL.md +++ b/python/rafter_cli/resources/skills/rafter-code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: rafter-code-review -description: "REQUIRED before declaring a task done when the diff touches user input, SQL, shell, auth, credentials, file paths, serialization, or dependency surface. Walks OWASP/ASVS/MITRE questions the compiler and the test suite won't catch. Pair with `rafter run` on the same diff. A security-relevant task marked complete without both has been under-delivered — do not claim done." +description: "REQUIRED before declaring a task done when the diff touches user input, SQL, shell, auth, credentials, file paths, serialization, crypto, network endpoints, data deletion, or dependency surface. Judge by that surface, not the task label — research/experimental/local-only code with none of it can skip this. Walks OWASP/ASVS/MITRE questions the compiler and the test suite won't catch. Pair with `rafter run` on the same diff. When the diff has that surface, marking it complete without both has been under-delivered — do not claim done." version: 0.7.0 allowed-tools: [Bash, Read, Glob, Grep] --- @@ -11,6 +11,12 @@ A reviewer's skill, not an audit generator. Each sub-doc is a set of **questions > Pair with the `rafter` skill (detection: `rafter scan`, `rafter run`) and `rafter-secure-design` (prevention: design-phase walks). This skill is the middle stage — review before merge. +## When this applies (and when it doesn't) + +Scoped to the **security surface of the diff**, not the task's label. Walk it fully when the change touches: user / untrusted input, SQL or query building, shell / `exec` / subprocess, auth or access control, credentials / secrets / tokens, file paths or uploads, (de)serialization, crypto, network-facing endpoints or outbound fetchers, data deletion, or dependency / manifest changes. + +If **none** of those are present — research / experimental / exploratory / local-only / throwaway code such as training scripts, data analysis, plotting, model eval, notebooks, or pure computation over trusted local data — a quick surface check is enough; you don't need to walk the full review or pair `rafter run`. But the check is the surface, not the label: research code that reads a secret, shells out, hits the network, or parses untrusted bytes is back on the engage list and gets the full walk. + ## How to use this skill 1. Identify the category of code in front of you (below). diff --git a/python/rafter_cli/resources/skills/rafter-secure-design/SKILL.md b/python/rafter_cli/resources/skills/rafter-secure-design/SKILL.md index aebadfa..d25e96d 100644 --- a/python/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +++ b/python/rafter_cli/resources/skills/rafter-secure-design/SKILL.md @@ -1,6 +1,6 @@ --- name: rafter-secure-design -description: "REQUIRED before writing code for any feature touching auth, payments, credentials, tokens, sessions, file upload, user data, or data deletion. Invoke it, record answers inline in the design doc or PR description, then write the code. Skipping this is how auth bypass, token leakage, and delete-the-wrong-record bugs ship. If the feature touches any of those surfaces and this hasn't run, the design is incomplete — do not proceed to implementation." +description: "REQUIRED before writing code for any feature touching auth, payments, credentials, tokens, sessions, file upload, user data, untrusted input, deserialization, network endpoints, or data deletion. Scope by that surface, not the task label — a research/experimental/local-only feature with none of it doesn't need this. Invoke it, record answers inline in the design doc or PR description, then write the code. Skipping this is how auth bypass, token leakage, and delete-the-wrong-record bugs ship. If the feature touches any of those surfaces and this hasn't run, the design is incomplete — do not proceed to implementation." version: 0.1.0 allowed-tools: [Read, Glob, Grep] --- @@ -11,6 +11,12 @@ A designer's skill, not a scanner. The goal is to catch the flaw in the whiteboa > Pair with `rafter-code-review` (structured review *during* PR) and the `rafter` skill (automated detection of what slipped through). This skill is the earliest stage — prevention before the code exists. +## When this applies (and when it doesn't) + +Engage this design walk when the feature you're about to build touches a real security surface: auth or access control, credentials / secrets / tokens / sessions, user or untrusted input, SQL or query construction, shell / `exec`, file paths or uploads, (de)serialization, crypto, network-facing endpoints or outbound fetchers, data deletion, or new dependencies. + +If the thing you're designing has **none** of that — a research / experimental / local-only / throwaway piece such as a training script, analysis pipeline, plot, model-eval harness, notebook, or pure computation over trusted local data — a quick surface check is enough and you can proceed to implementation without the full walk. Judge by the surface, not by whether the work is called "research": a research feature that stores user data, handles a token, or opens a network endpoint is back on the engage list. + ## How to use this skill 1. Identify what's being designed (below). If multiple apply, walk them in the order listed — `threat-modeling` last, as a capstone. diff --git a/python/rafter_cli/resources/skills/rafter/SKILL.md b/python/rafter_cli/resources/skills/rafter/SKILL.md index 8931326..4e9f5bc 100644 --- a/python/rafter_cli/resources/skills/rafter/SKILL.md +++ b/python/rafter_cli/resources/skills/rafter/SKILL.md @@ -1,12 +1,36 @@ --- name: rafter -description: "Entry point for rafter. Invoke when a sub-skill is unclear, or when the task needs `rafter run` (remote SAST+SCA), `rafter secrets` (local secrets-only), `rafter audit`, policy checks, or command-risk evaluation. If a task is security-relevant and no rafter skill or CLI call has been made, invoke this before handing the task off — an un-evaluated \"done\" on security-relevant work is not done." +description: "Entry point for rafter. Invoke when a sub-skill is unclear, or when the task needs `rafter run` (remote SAST+SCA), `rafter secrets` (local secrets-only), `rafter audit`, policy checks, or command-risk evaluation. Scope by security surface, not task label: engage when the diff touches auth, credentials/secrets/tokens, untrusted input, SQL, shell/exec, file paths, deserialization, crypto, network endpoints, data deletion, or dependencies; for research/experimental/local-only code with none of that, a quick surface check is enough. When such surface IS present and no rafter skill or CLI call has been made, invoke this before handing the task off — an un-evaluated \"done\" on genuine security surface is not done." version: 0.7.0 allowed-tools: [Bash, Read] --- # Rafter — Security Toolkit for AI Workflows +## When Rafter applies (and when it doesn't) + +Rafter is a **surface-driven** gate, not a task-label gate. Before you engage — or decide not to — do a 10-second read of the diff's actual security surface. Let that answer, not whether the work is called "research", pick the branch. + +**Engage fully** (walk the review + run `rafter run`) when the change touches any of: + +- auth, sessions, or access control +- credentials, secrets, tokens, or keys +- user-supplied or otherwise untrusted input +- SQL or any other query / command construction +- shell, `exec`, or subprocess invocation +- file paths — reads, writes, uploads, traversal +- deserialization or parsing of untrusted data +- crypto (choosing or using primitives) +- network-facing endpoints or outbound fetchers (SSRF surface) +- data deletion or other destructive mutations +- dependency, lockfile, or manifest changes + +**Back off** when the change has **none** of the above — research / experimental / exploratory / local-only / throwaway code: training scripts, data analysis, plotting, model eval, notebooks, pure computation over trusted local data. A quick surface check is enough, and if there is genuinely no security surface it is fine to proceed **without** the full `rafter-code-review` + `rafter run`. + +**The rule that decides it:** the "research" label buys nothing. Research code that reads a secret, shells out, hits the network, parses untrusted input, or bumps a dependency is on the engage list and gets the full gate. Judge by the surface of the diff, not by what the task is called. + +--- + ## Picking the right tier — DO NOT stop at "local" Rafter ships three tiers. **They are not interchangeable.** The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. From a6dd699a37a77e361460cda9c97d7489d0774c00 Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:03:00 -0700 Subject: [PATCH 05/14] fix(agent): scope injected instruction block by surface, not task label (sable-m9n6) (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rafter agent init` injects RAFTER_INSTRUCTION_BLOCK into agents' instruction files (CLAUDE.md, and ~/.codex/AGENTS.md — a GLOBAL Codex instructions file). The block was a blanket gate ("A security-relevant task is not complete until reviewed... Stop and invoke before continuing") with no scoping. Because Codex reads ~/.codex/AGENTS.md as global instructions, every Codex session was ordered to treat its task as rafter-gated — agents went to read rafter's SKILL.md instead of doing the requested (often no-surface, research) work. Instruction-level hijacking. This applies the same surface-based scoping that PR #195 gave the skill prompts, to the injected block itself, so block and skills tell the same story: - Engage the gate when the change touches a real security surface (auth, secrets/ tokens, untrusted input, SQL/query, shell/exec, file paths, deserialization, crypto, network endpoints/SSRF, data deletion, dependencies). - Back off when NONE of those are present — research/experimental/local-only/ throwaway code (training scripts, analysis, plotting, model eval, notebooks, pure computation over trusted local data): a quick surface check is enough. - Decisive rule: judge by the diff's actual surface, not the "research" label — research code that reads a secret, shells out, hits the network, parses untrusted input, or bumps a dependency still gets the full gate. Guardrail: this reduces false engagement on no-surface code without removing or softening protection for any listed surface — the routing bullets were expanded (crypto, network/SSRF, data deletion, dependencies) so coverage strengthens, not weakens. node and python RAFTER_INSTRUCTION_BLOCK remain byte-identical. Also (sable-h0ah): narrow the Codex .codex/hooks.json PostToolUse matcher from `.*` to `Bash|apply_patch` so posttool stops firing on every Read/MCP call (log noise + latency), matching Codex's own PreToolUse surface and the claude-code posttool scoping. Applied in init.ts, components.ts, and the python mirror; the one test asserting the old value is updated. AI-assisted (Claude Opus 4.8) per repo AI Policy. Co-authored-by: Claude Opus 4.8 --- node/src/commands/agent/components.ts | 6 ++-- node/src/commands/agent/init.ts | 9 +++--- node/src/commands/agent/instruction-block.ts | 26 ++++++++++----- node/tests/platform-integration.test.ts | 5 +-- .../rafter_cli/commands/agent_components.py | 32 +++++++++++++------ 5 files changed, 52 insertions(+), 26 deletions(-) diff --git a/node/src/commands/agent/components.ts b/node/src/commands/agent/components.ts index 969b8e9..633eee0 100644 --- a/node/src/commands/agent/components.ts +++ b/node/src/commands/agent/components.ts @@ -346,9 +346,11 @@ function codexHooks(): ComponentSpec { cfg.hooks.PostToolUse, (e) => hookEntryMatchesRafter(e, "rafter hook posttool"), ); - // Bash + apply_patch per Codex hook docs (rf-ovql verification). + // Bash + apply_patch per Codex hook docs (rf-ovql verification). PostToolUse + // mirrors that write/exec surface (narrowed from `.*` to skip Read/MCP log + // noise), matching claude-code posttool scoping (sable-h0ah). cfg.hooks.PreToolUse.push({ matcher: "Bash|apply_patch", hooks: [pre] }); - cfg.hooks.PostToolUse.push({ matcher: ".*", hooks: [post] }); + cfg.hooks.PostToolUse.push({ matcher: "Bash|apply_patch", hooks: [post] }); writeJson(hooksPath, cfg); }, uninstall: () => { diff --git a/node/src/commands/agent/init.ts b/node/src/commands/agent/init.ts index 0b85eb4..eadb97d 100644 --- a/node/src/commands/agent/init.ts +++ b/node/src/commands/agent/init.ts @@ -108,7 +108,7 @@ function printDryRunPlan(plan: { if (plan.wantCodex) { console.log(); console.log("Codex CLI (--with-codex):"); - W(path.join(plan.root, ".codex", "hooks.json"), "PreToolUse: Bash|apply_patch, PostToolUse: .*"); + W(path.join(plan.root, ".codex", "hooks.json"), "PreToolUse: Bash|apply_patch, PostToolUse: Bash|apply_patch"); for (const s of AGENT_SKILLS) { W(path.join(plan.root, ".agents", "skills", s.name, "SKILL.md")); } @@ -383,10 +383,11 @@ function installCodexHooks(root: string): void { config.hooks.PreToolUse.push( { matcher: "Bash|apply_patch", hooks: [preHook] }, ); - // PostToolUse fires for the same tool surface; .* keeps all events in the - // audit log without filtering. + // PostToolUse mirrors the same write/exec surface (Bash + apply_patch); + // narrowed from `.*` so posttool doesn't fire on every Read/MCP call + // (log noise + latency), matching the claude-code posttool scoping (sable-h0ah). config.hooks.PostToolUse.push( - { matcher: ".*", hooks: [postHook] }, + { matcher: "Bash|apply_patch", hooks: [postHook] }, ); fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2), "utf-8"); diff --git a/node/src/commands/agent/instruction-block.ts b/node/src/commands/agent/instruction-block.ts index 9e15ca7..322ac10 100644 --- a/node/src/commands/agent/instruction-block.ts +++ b/node/src/commands/agent/instruction-block.ts @@ -6,18 +6,28 @@ export const RAFTER_MARKER_END = ""; /** Rafter security context block for agent instruction files (CLAUDE.md, AGENTS.md, etc.) */ export const RAFTER_INSTRUCTION_BLOCK = `${RAFTER_MARKER_START} -## Security: Rafter (required review gate) +## Security: Rafter (surface-driven review gate) -Rafter is this project's security review gate. A security-relevant task is -**not complete** until a rafter skill (or \`rafter run\`) has reviewed it. -Don't mark done, don't hand off, don't open a PR without that pass. +Rafter is this project's security review gate — driven by the change's **security +surface**, not by the task label. When a diff touches a real surface (below), it is +**not complete** until a rafter skill (or \`rafter run\`) has reviewed it: don't mark +done, don't hand off, don't open a PR without that pass. When it touches **none** of +that surface — research / experimental / local-only / throwaway code (training +scripts, data analysis, plotting, model eval, notebooks, pure computation over +trusted local data) — a quick surface check is enough; proceed without the full +gate. Judge by the diff's actual surface, not the "research" label: research code +that reads a secret, shells out, hits the network, parses untrusted input, or bumps +a dependency still gets the full gate. -**Stop and invoke before continuing:** +**Stop and invoke when the change touches that surface:** - Designing auth, payments, credentials, tokens, sessions, file upload, - user data, or data deletion → **\`rafter-secure-design\`** (before writing code). -- Diff touches user input, SQL, shell, auth, credentials, file paths, or - serialization → **\`rafter-code-review\`** + \`rafter run\` (before declaring done). + user/untrusted data, deserialization, network endpoints, or data deletion + → **\`rafter-secure-design\`** (before writing code). +- Diff touches user/untrusted input, SQL/query building, shell/exec, auth, + credentials, file paths, (de)serialization, crypto, network endpoints/outbound + fetchers (SSRF), data deletion, or dependencies → **\`rafter-code-review\`** + + \`rafter run\` (before declaring done). - About to install or forward a third-party SKILL.md, MCP manifest, Cursor rule, or agent config → **\`rafter-skill-review\`** (before copying anywhere). - Security-adjacent but the angle isn't clear → **\`rafter\`** (the router skill, diff --git a/node/tests/platform-integration.test.ts b/node/tests/platform-integration.test.ts index 8e2af9d..f5a59fa 100644 --- a/node/tests/platform-integration.test.ts +++ b/node/tests/platform-integration.test.ts @@ -1367,12 +1367,13 @@ describe("Platform Integration — MCP Installs via CLI", () => { // Codex matchers per developers.openai.com/codex/hooks (rf-ovql verified): // PreToolUse intercepts Bash + apply_patch (file edits via apply_patch). - // PostToolUse is catch-all so all completed events land in audit.jsonl. + // PostToolUse mirrors that write/exec surface (narrowed from `.*` to skip + // Read/MCP log noise), matching claude-code posttool scoping (sable-h0ah). const preMatchers = config.hooks.PreToolUse.map((e: any) => e.matcher); expect(preMatchers).toContain("Bash|apply_patch"); const postMatchers = config.hooks.PostToolUse.map((e: any) => e.matcher); - expect(postMatchers).toContain(".*"); + expect(postMatchers).toContain("Bash|apply_patch"); }); it("should deduplicate hooks on repeated installs", () => { diff --git a/python/rafter_cli/commands/agent_components.py b/python/rafter_cli/commands/agent_components.py index acb2382..b1c1f71 100644 --- a/python/rafter_cli/commands/agent_components.py +++ b/python/rafter_cli/commands/agent_components.py @@ -31,18 +31,28 @@ RAFTER_MARKER_END = "" RAFTER_INSTRUCTION_BLOCK = f"""{RAFTER_MARKER_START} -## Security: Rafter (required review gate) +## Security: Rafter (surface-driven review gate) -Rafter is this project's security review gate. A security-relevant task is -**not complete** until a rafter skill (or `rafter run`) has reviewed it. -Don't mark done, don't hand off, don't open a PR without that pass. +Rafter is this project's security review gate — driven by the change's **security +surface**, not by the task label. When a diff touches a real surface (below), it is +**not complete** until a rafter skill (or `rafter run`) has reviewed it: don't mark +done, don't hand off, don't open a PR without that pass. When it touches **none** of +that surface — research / experimental / local-only / throwaway code (training +scripts, data analysis, plotting, model eval, notebooks, pure computation over +trusted local data) — a quick surface check is enough; proceed without the full +gate. Judge by the diff's actual surface, not the "research" label: research code +that reads a secret, shells out, hits the network, parses untrusted input, or bumps +a dependency still gets the full gate. -**Stop and invoke before continuing:** +**Stop and invoke when the change touches that surface:** - Designing auth, payments, credentials, tokens, sessions, file upload, - user data, or data deletion → **`rafter-secure-design`** (before writing code). -- Diff touches user input, SQL, shell, auth, credentials, file paths, or - serialization → **`rafter-code-review`** + `rafter run` (before declaring done). + user/untrusted data, deserialization, network endpoints, or data deletion + → **`rafter-secure-design`** (before writing code). +- Diff touches user/untrusted input, SQL/query building, shell/exec, auth, + credentials, file paths, (de)serialization, crypto, network endpoints/outbound + fetchers (SSRF), data deletion, or dependencies → **`rafter-code-review`** + + `rafter run` (before declaring done). - About to install or forward a third-party SKILL.md, MCP manifest, Cursor rule, or agent config → **`rafter-skill-review`** (before copying anywhere). - Security-adjacent but the angle isn't clear → **`rafter`** (the router skill, @@ -353,9 +363,11 @@ def install() -> None: post = {"type": "command", "command": "rafter hook posttool"} h["PreToolUse"] = _filter_hooks(h["PreToolUse"], lambda e: _hook_entry_has_rafter(e, "rafter hook pretool")) h["PostToolUse"] = _filter_hooks(h["PostToolUse"], lambda e: _hook_entry_has_rafter(e, "rafter hook posttool")) - # Bash + apply_patch per Codex hook docs (rf-ovql verification). + # Bash + apply_patch per Codex hook docs (rf-ovql verification). PostToolUse + # mirrors that write/exec surface (narrowed from ".*" to skip Read/MCP log + # noise), matching claude-code posttool scoping (sable-h0ah). h["PreToolUse"].append({"matcher": "Bash|apply_patch", "hooks": [pre]}) - h["PostToolUse"].append({"matcher": ".*", "hooks": [post]}) + h["PostToolUse"].append({"matcher": "Bash|apply_patch", "hooks": [post]}) _write_json(hooks_path, cfg) def uninstall() -> None: From 8c7c949824de39d8a17d7c162d332155c8f12761 Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:07:49 -0700 Subject: [PATCH 06/14] fix(agent): correct Codex hooks dry-run plan string to match narrowed PostToolUse matcher (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python dry-run preview still printed 'PostToolUse: .*' after #196 narrowed the actual Codex PostToolUse matcher to Bash|apply_patch (Node's equivalent plan string was updated; Python's was missed). Cosmetic parity fix — the installed matcher was already correct; only the printed plan was stale. Co-authored-by: Claude Opus 4.8 --- python/rafter_cli/commands/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/rafter_cli/commands/agent.py b/python/rafter_cli/commands/agent.py index 44ad216..541e30f 100644 --- a/python/rafter_cli/commands/agent.py +++ b/python/rafter_cli/commands/agent.py @@ -332,7 +332,7 @@ def R(p: Path, note: str = "") -> None: if want_codex: print() print("Codex CLI (--with-codex):") - W(root / ".codex" / "hooks.json", "PreToolUse: Bash|apply_patch, PostToolUse: .*") + W(root / ".codex" / "hooks.json", "PreToolUse: Bash|apply_patch, PostToolUse: Bash|apply_patch") for s in _AGENT_SKILLS: W(root / ".agents" / "skills" / s["name"] / "SKILL.md") agents_md = root / ".codex" / "AGENTS.md" if scope == "user" else root / "AGENTS.md" From 4764143d5a4be740c9f2ba6c03a0ff45f5af8c68 Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:39:29 -0700 Subject: [PATCH 07/14] fix(deps): pin cryptography >=46.0.7 (GHSA-537c-gmf6-5ccf) and resync stale poetry.lock (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit cryptography constraint to pin the in-major fix for GHSA-537c-gmf6-5ccf (held to 46.x to avoid the unrelated 49.x jump). While regenerating, found poetry.lock was STALE and violated its own pyproject constraints — the previously-declared Python security bumps were never locked, so the vulnerable versions were still what actually installed: python-dotenv ^1.2.2 -> lock had 1.0.1 (now 1.2.2) requests ^2.33.0 -> lock had 2.32.4 (now 2.34.2) urllib3 >=2.7.0,<3 -> lock had 2.6.3 (now 2.7.0) cryptography (new pin) -> lock had 46.0.5 (now 46.0.7) lock-version 2.1 preserved (regenerated with Poetry 2.4.1). `poetry check --lock` passes. Python suite: 1495 passed against the new locked deps. Co-authored-by: Claude Opus 4.8 --- python/poetry.lock | 134 +++++++++++++++++++++--------------------- python/pyproject.toml | 3 + 2 files changed, 70 insertions(+), 67 deletions(-) diff --git a/python/poetry.lock b/python/poetry.lock index a5da25d..80a410e 100644 --- a/python/poetry.lock +++ b/python/poetry.lock @@ -286,61 +286,61 @@ markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" groups = ["main"] files = [ - {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"}, - {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"}, - {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"}, - {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"}, - {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"}, - {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"}, - {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"}, - {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"}, - {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"}, - {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"}, - {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"}, - {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"}, - {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"}, - {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"}, - {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"}, - {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"}, + {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, + {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, + {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, + {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, + {file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, + {file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, + {file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, + {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, + {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, + {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, + {file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, + {file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, + {file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, + {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, + {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, + {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, + {file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, + {file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, + {file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, + {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, + {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, + {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, + {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, + {file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, + {file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, ] [package.dependencies] @@ -354,7 +354,7 @@ nox = ["nox[uv] (>=2024.4.15)"] pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -878,14 +878,14 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, ] [package.extras] @@ -1036,25 +1036,25 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "requests" -version = "2.32.4" +version = "2.34.2" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rich" @@ -1345,14 +1345,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] @@ -1433,4 +1433,4 @@ watchmedo = ["PyYAML (>=3.10)"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "7607649fe020e81b38655d25fad6ce1c592fef36ad92f89134702cd56fef1228" +content-hash = "d98b50867f90e8a21791a83855e4c69c26097c3309e7406551f54a9e658ea2d0" diff --git a/python/pyproject.toml b/python/pyproject.toml index 6756bc2..94bc9cf 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -17,6 +17,9 @@ urllib3 = ">=2.7.0,<3" pyyaml = "^6.0.1" mcp = "^1.9.0" watchdog = "^4.0.0" +# Security pin for a transitive dep: GHSA-537c-gmf6-5ccf (fixed in 46.0.7). +# Held in the 46.x major line to avoid the unrelated 49.x jump. +cryptography = ">=46.0.7,<47" [tool.poetry.scripts] rafter = "rafter_cli.__main__:app" From eb8d4ae40cf9583127ce7279d6205abfe0c85312 Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:43:50 -0700 Subject: [PATCH 08/14] fix(skills): restore rafter SKILL.md line budget broken by #195 (#199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #195 added the surface-scoping section and grew the rafter router SKILL.md from 118 -> 142 lines, blowing BOTH line budgets (node <120, python <130). With no CI, those two tests have been red on main since it merged. - Condense SKILL.md 142 -> 128 lines: the 11-bullet engage list becomes a compact inline list (every surface kept) and the tier section is tightened. No content dropped — the scoping rule stays in the router, since it decides whether the skill is entered at all and can't be pushed into a sub-doc. - Align the node budget 120 -> 130 to match python's. The limits had drifted: python was bumped 120 -> 130 for the scanner-scope section; node never was. Tests: node brief.test.ts 28 passed; python test_brief.py 35 passed. node/python SKILL.md byte-identical. Co-authored-by: Claude Opus 4.8 --- node/resources/skills/rafter/SKILL.md | 30 +++++-------------- node/tests/brief.test.ts | 12 ++++++-- .../resources/skills/rafter/SKILL.md | 30 +++++-------------- 3 files changed, 25 insertions(+), 47 deletions(-) diff --git a/node/resources/skills/rafter/SKILL.md b/node/resources/skills/rafter/SKILL.md index 4e9f5bc..ea4f8ee 100644 --- a/node/resources/skills/rafter/SKILL.md +++ b/node/resources/skills/rafter/SKILL.md @@ -9,23 +9,11 @@ allowed-tools: [Bash, Read] ## When Rafter applies (and when it doesn't) -Rafter is a **surface-driven** gate, not a task-label gate. Before you engage — or decide not to — do a 10-second read of the diff's actual security surface. Let that answer, not whether the work is called "research", pick the branch. +Rafter is a **surface-driven** gate, not a task-label gate. Read the diff's actual security surface first; let that — not whether the work is called "research" — pick the branch. -**Engage fully** (walk the review + run `rafter run`) when the change touches any of: +**Engage fully** (walk the review + run `rafter run`) when the change touches any of: auth / sessions / access control · credentials, secrets, tokens, keys · user-supplied or otherwise untrusted input · SQL or any other query / command construction · shell, `exec`, or subprocess invocation · file paths (read, write, upload, traversal) · deserialization or parsing of untrusted data · crypto primitives · network-facing endpoints or outbound fetchers (SSRF surface) · data deletion or other destructive mutations · dependency, lockfile, or manifest changes. -- auth, sessions, or access control -- credentials, secrets, tokens, or keys -- user-supplied or otherwise untrusted input -- SQL or any other query / command construction -- shell, `exec`, or subprocess invocation -- file paths — reads, writes, uploads, traversal -- deserialization or parsing of untrusted data -- crypto (choosing or using primitives) -- network-facing endpoints or outbound fetchers (SSRF surface) -- data deletion or other destructive mutations -- dependency, lockfile, or manifest changes - -**Back off** when the change has **none** of the above — research / experimental / exploratory / local-only / throwaway code: training scripts, data analysis, plotting, model eval, notebooks, pure computation over trusted local data. A quick surface check is enough, and if there is genuinely no security surface it is fine to proceed **without** the full `rafter-code-review` + `rafter run`. +**Back off** when the change touches **none** of those — research / experimental / exploratory / local-only / throwaway code: training scripts, data analysis, plotting, model eval, notebooks, pure computation over trusted local data. A quick surface check is enough; with no security surface, proceed **without** the full `rafter-code-review` + `rafter run`. **The rule that decides it:** the "research" label buys nothing. Research code that reads a secret, shells out, hits the network, parses untrusted input, or bumps a dependency is on the engage list and gets the full gate. Judge by the surface of the diff, not by what the task is called. @@ -33,15 +21,13 @@ Rafter is a **surface-driven** gate, not a task-label gate. Before you engage ## Picking the right tier — DO NOT stop at "local" -Rafter ships three tiers. **They are not interchangeable.** The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. - -1. **Local (`rafter secrets`)** — secrets only. Regex + betterleaks for hardcoded API keys, tokens, private keys. Fast, offline, no key. **This is NOT a code security scan.** It will not find SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. If an agent's entire rafter interaction was `rafter secrets .` and it exited clean, the agent has done secret-hygiene only — not security review. -2. **Remote fast (`rafter run`, default mode)** — SAST + SCA + secrets via the Rafter API. This is the real code-analysis pass: dataflow, taint, known-vulnerable dependencies, crypto misuse, injection sinks. Needs `RAFTER_API_KEY`. -3. **Remote plus (`rafter run --mode plus`)** — agentic deep-dive: LLM-guided investigation of suspicious patterns the rules engine flags. Slower, higher signal. Code is deleted server-side after the run. +Three tiers, **not interchangeable**. The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. -**Default expectation for a security-relevant task**: run `rafter run`. Fall back to `rafter secrets` only when no API key is available or you specifically need offline secret-hygiene. If you've only run the local scanner, say so explicitly — don't claim the code was "scanned" without qualification. +1. **`rafter secrets`** — hardcoded credentials only (regex + betterleaks). Fast, offline, no key. **NOT a code security scan** — it finds no SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. A clean `rafter secrets .` is secret-hygiene, not security review. +2. **`rafter run`** (default mode) — the real code-analysis pass: SAST + SCA + secrets (dataflow, taint, known-vulnerable deps, crypto misuse, injection sinks). Needs `RAFTER_API_KEY`. +3. **`rafter run --mode plus`** — agentic deep-dive: LLM-guided investigation of what the rules engine flags. Slower, higher signal; code is deleted server-side after the run. -Stable exit codes, stable JSON shapes, deterministic findings. Safe to chain in CI and in agent loops. +**Default for a security-relevant task: `rafter run`.** Fall back to `rafter secrets` only when no API key is available — and say so explicitly; don't claim the code was "scanned" without qualification. Deterministic findings, stable exit codes and JSON shapes — safe to chain in CI and in agent loops. --- diff --git a/node/tests/brief.test.ts b/node/tests/brief.test.ts index 48c62ca..b77b0e5 100644 --- a/node/tests/brief.test.ts +++ b/node/tests/brief.test.ts @@ -145,15 +145,21 @@ describe("brief command — setup guides", () => { }); describe("rafter skill — CYOA hierarchy", () => { - it("top-level SKILL.md parses and stays under 120 lines", () => { + it("top-level SKILL.md parses and stays under 130 lines", () => { const skillPath = path.join(RAFTER_SKILL_DIR, "SKILL.md"); const raw = readFileSync(skillPath, "utf-8"); expect(raw.startsWith("---")).toBe(true); // frontmatter closes (two `---` markers) expect(raw.match(/^---$/gm)?.length ?? 0).toBeGreaterThanOrEqual(2); + // 130-line budget, matching the Python suite. The limits had drifted apart: + // Python was bumped 120 -> 130 for the scanner-scope section, Node never was. + // The surface-scoping section ("when Rafter applies") has to live in the + // router itself — it decides whether the skill is entered at all — so it + // can't be pushed into a sub-doc. Budget is tight on purpose: trim before + // you add. const lines = raw.split("\n").length; - expect(lines, `SKILL.md is ${lines} lines; keep under 120`).toBeLessThan( - 120, + expect(lines, `SKILL.md is ${lines} lines; keep under 130`).toBeLessThan( + 130, ); }); diff --git a/python/rafter_cli/resources/skills/rafter/SKILL.md b/python/rafter_cli/resources/skills/rafter/SKILL.md index 4e9f5bc..ea4f8ee 100644 --- a/python/rafter_cli/resources/skills/rafter/SKILL.md +++ b/python/rafter_cli/resources/skills/rafter/SKILL.md @@ -9,23 +9,11 @@ allowed-tools: [Bash, Read] ## When Rafter applies (and when it doesn't) -Rafter is a **surface-driven** gate, not a task-label gate. Before you engage — or decide not to — do a 10-second read of the diff's actual security surface. Let that answer, not whether the work is called "research", pick the branch. +Rafter is a **surface-driven** gate, not a task-label gate. Read the diff's actual security surface first; let that — not whether the work is called "research" — pick the branch. -**Engage fully** (walk the review + run `rafter run`) when the change touches any of: +**Engage fully** (walk the review + run `rafter run`) when the change touches any of: auth / sessions / access control · credentials, secrets, tokens, keys · user-supplied or otherwise untrusted input · SQL or any other query / command construction · shell, `exec`, or subprocess invocation · file paths (read, write, upload, traversal) · deserialization or parsing of untrusted data · crypto primitives · network-facing endpoints or outbound fetchers (SSRF surface) · data deletion or other destructive mutations · dependency, lockfile, or manifest changes. -- auth, sessions, or access control -- credentials, secrets, tokens, or keys -- user-supplied or otherwise untrusted input -- SQL or any other query / command construction -- shell, `exec`, or subprocess invocation -- file paths — reads, writes, uploads, traversal -- deserialization or parsing of untrusted data -- crypto (choosing or using primitives) -- network-facing endpoints or outbound fetchers (SSRF surface) -- data deletion or other destructive mutations -- dependency, lockfile, or manifest changes - -**Back off** when the change has **none** of the above — research / experimental / exploratory / local-only / throwaway code: training scripts, data analysis, plotting, model eval, notebooks, pure computation over trusted local data. A quick surface check is enough, and if there is genuinely no security surface it is fine to proceed **without** the full `rafter-code-review` + `rafter run`. +**Back off** when the change touches **none** of those — research / experimental / exploratory / local-only / throwaway code: training scripts, data analysis, plotting, model eval, notebooks, pure computation over trusted local data. A quick surface check is enough; with no security surface, proceed **without** the full `rafter-code-review` + `rafter run`. **The rule that decides it:** the "research" label buys nothing. Research code that reads a secret, shells out, hits the network, parses untrusted input, or bumps a dependency is on the engage list and gets the full gate. Judge by the surface of the diff, not by what the task is called. @@ -33,15 +21,13 @@ Rafter is a **surface-driven** gate, not a task-label gate. Before you engage ## Picking the right tier — DO NOT stop at "local" -Rafter ships three tiers. **They are not interchangeable.** The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. - -1. **Local (`rafter secrets`)** — secrets only. Regex + betterleaks for hardcoded API keys, tokens, private keys. Fast, offline, no key. **This is NOT a code security scan.** It will not find SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. If an agent's entire rafter interaction was `rafter secrets .` and it exited clean, the agent has done secret-hygiene only — not security review. -2. **Remote fast (`rafter run`, default mode)** — SAST + SCA + secrets via the Rafter API. This is the real code-analysis pass: dataflow, taint, known-vulnerable dependencies, crypto misuse, injection sinks. Needs `RAFTER_API_KEY`. -3. **Remote plus (`rafter run --mode plus`)** — agentic deep-dive: LLM-guided investigation of suspicious patterns the rules engine flags. Slower, higher signal. Code is deleted server-side after the run. +Three tiers, **not interchangeable**. The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. -**Default expectation for a security-relevant task**: run `rafter run`. Fall back to `rafter secrets` only when no API key is available or you specifically need offline secret-hygiene. If you've only run the local scanner, say so explicitly — don't claim the code was "scanned" without qualification. +1. **`rafter secrets`** — hardcoded credentials only (regex + betterleaks). Fast, offline, no key. **NOT a code security scan** — it finds no SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. A clean `rafter secrets .` is secret-hygiene, not security review. +2. **`rafter run`** (default mode) — the real code-analysis pass: SAST + SCA + secrets (dataflow, taint, known-vulnerable deps, crypto misuse, injection sinks). Needs `RAFTER_API_KEY`. +3. **`rafter run --mode plus`** — agentic deep-dive: LLM-guided investigation of what the rules engine flags. Slower, higher signal; code is deleted server-side after the run. -Stable exit codes, stable JSON shapes, deterministic findings. Safe to chain in CI and in agent loops. +**Default for a security-relevant task: `rafter run`.** Fall back to `rafter secrets` only when no API key is available — and say so explicitly; don't claim the code was "scanned" without qualification. Deterministic findings, stable exit codes and JSON shapes — safe to chain in CI and in agent loops. --- From 6e7543930e053d3fd40175bb004ae2a8e144be2d Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:20:32 -0700 Subject: [PATCH 09/14] fix(hook): argument-aware command matching so quoted prose isn't run as a command (sable-4v6e) (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pretool hook hard-denied `gh pr create --body "…git push --force…"` and `git commit -m "don't git push --force"` because it matched risk/policy patterns against the RAW command line — quoted DATA (a PR body, a commit message) was treated as if it were the command. Two compounding defects: 1. Naive matching over the whole line (command-interceptor `matchesPattern` and risk-rules `assessCommandRisk`) saw inside quoted arguments. 2. A `policy.blockedPatterns` hit hard-coded riskLevel:"critical" — a hard deny that ignored the assessed risk — and the default deny-list held loose literals (the substring "rm -rf /" also denied `rm -rf /tmp/build`, which is only HIGH). Fix — an argument-aware sanitizer (`sanitizeCommandForMatching`, new in both risk-rules.ts and risk_rules.py) that all risk/policy matching now runs against: - Tokenize respecting single/double quotes, escapes, `$(…)`/backticks, redirects; split on chain operators (`;` `&&` `||` `|` `&`), kept in place so pipeline rules (`curl … | bash`) still match. - Redact only spans that are DATA: operands of text commands (echo/printf/grep/…), values of prose flags (`-m`/`--message`/`--body`/`--title`/…), and quoted multi-word prose arguments to ordinary commands. - Preserve and RECURSIVELY sanitize spans that are CODE: a shell's `-c` argument (bash/sh/zsh/…), `$(…)`/backticks outside single quotes, eval-style flags (`-c`/`-e`/`--eval`/`-exec`) and execs (eval/exec/ssh/xargs); prefix wrappers (sudo/env/timeout/nice/…) delegate to the command they wrap. The executable token is unquoted so quoting the command name (`"rm" -rf /`, `watch "rm -rf /"`) can't break the anchors. Recursion depth-capped at 8; fails safe (stays flagged). So `bash -c "rm -rf /"` still hard-blocks (its quoted arg IS a command), while `echo "rm -rf /"` is DATA and allowed. Defect 2: a deny-list match now reports the ASSESSED risk (still allowed:false), and DEFAULT_BLOCKED_PATTERNS is exactly the unconditional CRITICAL_PATTERNS set — so `git push --force` stays HIGH / approval-gated as intended, not silently coerced to a critical hard-deny. Node and Python mirrored (0 divergences across a 50-case differential corpus). Tests both directions in both suites; the old "quoting breaks rm -rf" known-gap tests now assert it's caught. Security: reviewed with the rafter agent (adversarial, ~20k differential-fuzz cases). It confirmed no catastrophic command moved from denied to silently allowed, no Node/Python divergence, and no DoS. It surfaced the exec-name quoting bypass (`"rm" -rf /`) — fixed here with tests — and two pre-existing, out-of-scope pattern-coverage gaps filed as sable-urvj (long-form `rm --recursive --force`) and sable-2t0w (Node audit-logger snake_case config). AI-assisted (Claude Opus 4.8) per the repo AI Policy. Co-authored-by: Claude Opus 4.8 --- node/src/core/command-interceptor.ts | 72 ++- node/src/core/risk-rules.ts | 443 ++++++++++++++++-- node/tests/command-interceptor-policy.test.ts | 8 +- node/tests/command-interceptor.test.ts | 90 ++++ node/tests/risk-rules-bypass.test.ts | 118 ++++- python/rafter_cli/core/command_interceptor.py | 61 ++- python/rafter_cli/core/risk_rules.py | 440 ++++++++++++++++- python/tests/test_command_interceptor.py | 87 ++++ .../tests/test_command_interceptor_policy.py | 8 +- python/tests/test_risk_rules.py | 103 ++++ 10 files changed, 1313 insertions(+), 117 deletions(-) diff --git a/node/src/core/command-interceptor.ts b/node/src/core/command-interceptor.ts index 2eb8101..573468e 100644 --- a/node/src/core/command-interceptor.ts +++ b/node/src/core/command-interceptor.ts @@ -1,6 +1,11 @@ import { ConfigManager } from "./config-manager.js"; import { AuditLogger } from "./audit-logger.js"; -import { assessCommandRisk, matchedCriticalPattern, CommandRiskLevel } from "./risk-rules.js"; +import { + assessCommandRisk, + matchedCriticalPattern, + sanitizeCommandForMatching, + CommandRiskLevel, +} from "./risk-rules.js"; export type { CommandRiskLevel } from "./risk-rules.js"; @@ -67,12 +72,19 @@ export class CommandInterceptor { }; } - // Check blocked patterns (always block) + // Check blocked patterns (always block). + // + // A deny-list match denies — that is what a deny-list is for — but it must + // NOT rewrite the command's risk. Reporting every deny-list hit as + // "critical" made the hook tell users a `gh pr create` was an irreversible + // system-damage command. The assessed risk is reported as assessed; the + // genuinely unconditional hard-blocks are the CRITICAL_PATTERNS handled + // above, and the default deny-list is exactly that set. for (const pattern of policy.blockedPatterns) { if (this.matchesPattern(command, pattern)) { return { command, - riskLevel: "critical", + riskLevel, allowed: false, requiresApproval: false, reason: `Matches blocked pattern: ${pattern}`, @@ -84,7 +96,6 @@ export class CommandInterceptor { // Check approval patterns for (const pattern of policy.requireApproval) { if (this.matchesPattern(command, pattern)) { - const riskLevel = this.assessRisk(command); return { command, riskLevel, @@ -96,46 +107,22 @@ export class CommandInterceptor { } } - // Check policy mode - if (policy.mode === "deny-list") { - // If not in blocked or approval lists, allow - return { - command, - riskLevel: this.assessRisk(command), - allowed: true, - requiresApproval: false - }; - } else if (policy.mode === "approve-dangerous") { - // Assess risk and require approval for high/critical - const riskLevel = this.assessRisk(command); - if (riskLevel === "high" || riskLevel === "critical") { - return { - command, - riskLevel, - allowed: false, - requiresApproval: true, - reason: `High risk command requires approval` - }; - } + // Check policy mode. `riskLevel` is the assessment made at the top of + // evaluate() — critical already returned, so it is high/medium/low here. + if (policy.mode === "approve-dangerous" && riskLevel === "high") { return { command, riskLevel, - allowed: true, - requiresApproval: false - }; - } else if (policy.mode === "allow-all") { - return { - command, - riskLevel: this.assessRisk(command), - allowed: true, - requiresApproval: false + allowed: false, + requiresApproval: true, + reason: `High risk command requires approval` }; } - // Default: allow + // deny-list / allow-all / unknown mode: not blocked, not approval-gated. return { command, - riskLevel: this.assessRisk(command), + riskLevel, allowed: true, requiresApproval: false }; @@ -154,15 +141,22 @@ export class CommandInterceptor { } /** - * Match command against pattern + * Match a command against a policy pattern. + * + * Matching runs against the SANITIZED command line, not the raw string: the + * policy patterns describe commands, so quoted text a command merely consumes + * as data (a commit message, a PR body) must not match them, while text a + * shell or eval wrapper executes (`bash -c "…"`) must. See + * `sanitizeCommandForMatching`. */ private matchesPattern(command: string, pattern: string): boolean { + const target = sanitizeCommandForMatching(command); try { const regex = new RegExp(pattern, "i"); - return regex.test(command); + return regex.test(target); } catch { // If pattern is not valid regex, try case-insensitive substring match - return command.toLowerCase().includes(pattern.toLowerCase()); + return target.toLowerCase().includes(pattern.toLowerCase()); } } diff --git a/node/src/core/risk-rules.ts b/node/src/core/risk-rules.ts index 9691d5b..491027d 100644 --- a/node/src/core/risk-rules.ts +++ b/node/src/core/risk-rules.ts @@ -1,6 +1,12 @@ /** * Centralized risk assessment rules. * Single source of truth — imported by command-interceptor, audit-logger, and config-defaults. + * + * Risk patterns are matched against a *sanitized* view of the command line + * (see `sanitizeCommandForMatching`), not the raw string: quoted text that a + * command consumes as DATA (a commit message, a PR body, an `echo` argument) + * must not be mistaken for a command, while quoted text that a shell or eval + * wrapper EXECUTES (`bash -c "…"`, `eval "…"`) must still be scanned. */ export type CommandRiskLevel = "low" | "medium" | "high" | "critical"; @@ -8,21 +14,29 @@ export type CommandRiskLevel = "low" | "medium" | "high" | "critical"; /** Directories where `rm -rf /` is catastrophic (data loss / unbootable). */ const CRITICAL_DIRS = "home|etc|usr|boot|root|sys|proc|lib|lib64|bin|sbin|opt"; -export const CRITICAL_PATTERNS: RegExp[] = [ +/** + * Catastrophic, irreversible commands. These are hard-blocked unconditionally — + * no policy, mode, or deny-list can opt out of them. Kept as pattern *sources* + * so the default policy deny-list (`DEFAULT_BLOCKED_PATTERNS`) is exactly this + * set, byte for byte, and can never drift from it. + */ +const CRITICAL_PATTERN_SOURCES: string[] = [ // rm -rf / (root only, any flag order) - new RegExp(`rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(\\s|$)`), - new RegExp(`rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(\\s|$)`), + `rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(\\s|$)`, + `rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(\\s|$)`, // rm -rf on critical top-level directories - new RegExp(`rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`), - new RegExp(`rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`), - /:\(\)\{\s*:\|:&\s*\};:/, // fork bomb - /dd\s+if=.*of=\/dev\/sd/, - />\s*\/dev\/sd/, - /mkfs/, - /fdisk/, - /parted/, + `rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`, + `rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`, + `:\\(\\)\\{\\s*:\\|:&\\s*\\};:`, // fork bomb + `dd\\s+if=.*of=/dev/sd`, + `>\\s*/dev/sd`, + `mkfs`, + `fdisk`, + `parted`, ]; +export const CRITICAL_PATTERNS: RegExp[] = CRITICAL_PATTERN_SOURCES.map((s) => new RegExp(s)); + export const HIGH_PATTERNS: RegExp[] = [ /rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*/, // rm -rf, -fr, -r -f, -f -r (any path) /rm\s+(-[a-z]*f[a-z]*\s+)*-[a-z]*r[a-z]*/, // rm -fr, reversed @@ -50,12 +64,14 @@ export const MEDIUM_PATTERNS: RegExp[] = [ /killall/, ]; -export const DEFAULT_BLOCKED_PATTERNS: string[] = [ - "rm -rf /", - ":(){ :|:& };:", - "dd if=/dev/zero of=/dev/sda", - "> /dev/sda", -]; +/** + * Default policy deny-list. Identical to the built-in unconditional hard-block + * set: a deny-list entry hard-denies, so the *defaults* must never match a + * merely approval-grade command. (The old literals — e.g. the substring + * "rm -rf /" — hard-denied `rm -rf /tmp/build`, which is HIGH and belongs in + * DEFAULT_REQUIRE_APPROVAL, not in a deny-list.) + */ +export const DEFAULT_BLOCKED_PATTERNS: string[] = [...CRITICAL_PATTERN_SOURCES]; export const DEFAULT_REQUIRE_APPROVAL: string[] = [ "rm -rf", @@ -70,20 +86,389 @@ export const DEFAULT_REQUIRE_APPROVAL: string[] = [ "git push .* \\+", ]; -/** Read-only commands whose arguments should not trigger risk patterns. */ -const SAFE_PREFIX = /^(grep|egrep|fgrep|rg|ag|ack|echo|printf)\s/; +// --------------------------------------------------------------------------- +// Argument-aware command sanitizer +// --------------------------------------------------------------------------- +// +// The risk patterns above describe *shell commands*. Matching them against the +// raw command line treats quoted argument text as if it were a command, so +// +// gh pr create --body "…don't git push --force…" +// git commit -m "don't git push --force" +// +// were flagged as force-pushes. Simply ignoring quoted text is NOT a fix: a +// shell or eval wrapper *executes* its quoted argument, so `bash -c "rm -rf /"` +// must still hard-block. The sanitizer is therefore argument-aware: +// +// * tokenize respecting quotes, escapes, command substitution and redirects; +// * split on chain operators (`;` `&&` `||` `|` `&`), keeping them in place so +// pipeline rules (`curl … | bash`) still match; +// * a shell's `-c` argument IS a command → recursively sanitize and inline it; +// * `$(…)` / backticks (outside single quotes) ARE commands → same; +// * arguments a command consumes as text (`echo`/`grep` operands, `-m`, +// `--body`, …) and prose-shaped quoted arguments are DATA → redacted; +// * everything else is preserved byte for byte. +// +// Known limitation: an *unrecognized* evaluator that takes a bare quoted command +// string with no `-c`/`-e`-style flag (e.g. a bespoke `myrunner "rm -rf /"`) has +// its argument treated as data. Anything reached through a real shell, an eval +// flag, a substitution, or an unquoted argument is still scanned. + +/** Shells whose `-c` argument is a command string to execute. */ +const SHELL_EXECS = new Set(["bash", "sh", "zsh", "dash", "ksh", "ash", "fish", "su"]); + +/** Execs whose arguments are executable text (a remote command, a script). */ +const EVAL_EXECS = new Set(["eval", "exec", "ssh", "sshpass", "xargs"]); + +/** Flags carrying an executable string (`bash -c`, `python -c`, `mysql -e`, `find -exec`). */ +const EVAL_FLAGS = new Set(["-c", "-e", "--command", "--execute", "--eval", "-exec", "--exec"]); + +/** Prefix wrappers that delegate to the command that follows them. */ +const TAIL_WRAPPERS = new Set([ + "sudo", "doas", "env", "nohup", "timeout", "nice", "ionice", + "time", "watch", "setsid", "stdbuf", "chrt", "command", +]); + +/** Commands whose operands are pure text data — searching or printing, never executing. */ +const TEXT_EXECS = new Set(["echo", "printf", "grep", "egrep", "fgrep", "rg", "ag", "ack"]); + +/** Flags whose value is human prose (a message, a body, a title) — never a command. */ +const TEXT_FLAGS = new Set([ + "-m", "--message", "--body", "--body-text", "--title", "--description", + "--reason", "--notes", "--subject", "--comment", "--annotation", +]); + +/** Operators that chain independent commands. */ +const CHAIN_OPS = new Set([";", "&&", "||", "|", "&"]); + +/** Operators whose following token is a redirect target (a path — never data). */ +const REDIRECT_OPS = new Set([">", ">>", "<", "<<"]); + +/** Bound on recursion through nested shell wrappers / substitutions. */ +const MAX_SANITIZE_DEPTH = 8; + +interface Piece { + /** Span in the source string. */ + start: number; + end: number; + /** Operator text, or null for a word. */ + op: string | null; + /** Unquoted, unescaped word content (empty for operators). */ + text: string; + /** Whether any part of the word was quoted. */ + quoted: boolean; + /** Contents of any command substitutions that the shell would execute. */ + substs: string[]; +} + +function isOpChar(c: string): boolean { + return c === ";" || c === "&" || c === "|" || c === ">" || c === "<"; +} + +/** Read a `$(…)` substitution starting at `i`; returns its contents and the next index. */ +function readSubst(s: string, i: number): { inner: string; next: number } { + let j = i + 2; + let depth = 1; + let inner = ""; + while (j < s.length && depth > 0) { + const ch = s[j]; + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) { j++; break; } + } + inner += ch; + j++; + } + return { inner, next: j }; +} + +/** Read a backtick substitution starting at `i`. */ +function readBacktick(s: string, i: number): { inner: string; next: number } { + let j = i + 1; + let inner = ""; + while (j < s.length && s[j] !== "`") { inner += s[j]; j++; } + return { inner, next: Math.min(j + 1, s.length) }; +} + +/** Split a command line into words and operators, respecting quotes and substitutions. */ +function tokenize(s: string): Piece[] { + const pieces: Piece[] = []; + let i = 0; + + while (i < s.length) { + const c = s[i]; + + if (/\s/.test(c)) { i++; continue; } + + if (isOpChar(c)) { + const start = i; + const two = s.slice(i, i + 2); + const op = (two === "&&" || two === "||" || two === ">>" || two === "<<") ? two : c; + i += op.length; + pieces.push({ start, end: i, op, text: op, quoted: false, substs: [] }); + continue; + } + + const start = i; + let text = ""; + let quoted = false; + const substs: string[] = []; + + while (i < s.length) { + const ch = s[i]; + if (/\s/.test(ch) || isOpChar(ch)) break; -/** Shell operators that chain independent commands. */ -const CHAIN_OPERATORS = /[;|&]|&&|\|\|/; + if (ch === "\\") { + i++; + if (i < s.length) { text += s[i]; i++; } + continue; + } + + // Single quotes are inert: no expansion, no substitution. + if (ch === "'") { + i++; + quoted = true; + while (i < s.length && s[i] !== "'") { text += s[i]; i++; } + i++; + continue; + } + + // Double quotes are data, but `$( )` / backticks inside them DO execute. + if (ch === '"') { + i++; + quoted = true; + while (i < s.length && s[i] !== '"') { + if (s[i] === "\\") { + i++; + if (i < s.length) { text += s[i]; i++; } + continue; + } + if (s[i] === "$" && s[i + 1] === "(") { + const r = readSubst(s, i); substs.push(r.inner); i = r.next; continue; + } + if (s[i] === "`") { + const r = readBacktick(s, i); substs.push(r.inner); i = r.next; continue; + } + text += s[i]; + i++; + } + i++; + continue; + } + + if (ch === "$" && s[i + 1] === "(") { + const r = readSubst(s, i); substs.push(r.inner); i = r.next; continue; + } + if (ch === "`") { + const r = readBacktick(s, i); substs.push(r.inner); i = r.next; continue; + } + + text += ch; + i++; + } + + pieces.push({ start, end: i, op: null, text, quoted, substs }); + } + + return pieces; +} + +/** `/usr/bin/rm` → `rm`; used to classify the executable of a segment. */ +function execName(text: string): string { + const base = text.slice(text.lastIndexOf("/") + 1); + return base.toLowerCase(); +} + +const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; +const SHELL_C_FLAG = /^-[a-z]*c$/; +const LONG_FLAG_WITH_VALUE = /^(--[a-z][a-z-]*)=/; + +interface Replacement { start: number; end: number; with: string; } + +/** + * Decide, for one segment (a single command in a chain), which spans are DATA + * and which are code, appending the resulting replacements. + */ +function processSegment(pieces: Piece[], depth: number, out: Replacement[]): void { + // A word is a redirect target when the piece before it is `>`/`>>`/`<`. + const isRedirectTarget = new Array(pieces.length).fill(false); + for (let i = 1; i < pieces.length; i++) { + const prev = pieces[i - 1]; + if (prev.op && REDIRECT_OPS.has(prev.op) && !pieces[i].op) isRedirectTarget[i] = true; + } + + // Effective executable: skip env assignments and prefix wrappers (`sudo`, + // `env`, `timeout 5`, `nice -n 15`, …) to reach the command they delegate to. + let execIdx = -1; + for (let i = 0; i < pieces.length; i++) { + const p = pieces[i]; + if (p.op || isRedirectTarget[i]) continue; + if (!p.quoted && ENV_ASSIGNMENT.test(p.text)) continue; + if (execIdx === -1 && !p.quoted && TAIL_WRAPPERS.has(execName(p.text))) { + // Skip the wrapper's own flags and their numeric/duration values. + let j = i + 1; + while (j < pieces.length) { + const q = pieces[j]; + if (q.op || isRedirectTarget[j]) { j++; continue; } + if (q.text.startsWith("-") || /^\d+[a-z]*$/i.test(q.text)) { j++; continue; } + break; + } + i = j - 1; + continue; + } + execIdx = i; + break; + } + + const exec = execIdx === -1 ? "" : execName(pieces[execIdx].text); + const isTextExec = TEXT_EXECS.has(exec); + + // A segment is code-carrying when some part of it is a command string the + // segment will execute: a shell, an eval-style flag, or an eval-style exec. + // Its quoted arguments are NOT prose and must stay visible to the patterns. + let hasShellExec = false; + let hasEvalFlag = false; + for (let i = 0; i < pieces.length; i++) { + const p = pieces[i]; + if (p.op || isRedirectTarget[i]) continue; + if (!p.quoted && SHELL_EXECS.has(execName(p.text))) hasShellExec = true; + if (!p.quoted && EVAL_FLAGS.has(p.text.toLowerCase())) hasEvalFlag = true; + } + const codeCarrying = hasShellExec || hasEvalFlag || EVAL_EXECS.has(exec); + + let seenShell = false; + let pendingScript = false; + let prevTextFlag = false; + + for (let i = 0; i < pieces.length; i++) { + const p = pieces[i]; + if (p.op) { prevTextFlag = false; continue; } + + const isExecTok = i === execIdx; + const flagName = p.text.toLowerCase(); + + if (!p.quoted && SHELL_EXECS.has(execName(p.text))) seenShell = true; + + // `bash -c