diff --git a/.rafter.yml b/.rafter.yml new file mode 100644 index 0000000..7fd6d35 --- /dev/null +++ b/.rafter.yml @@ -0,0 +1,96 @@ +# Rafter policy for the rafter-cli repo itself. +# +# Every `ignore:` entry below is a TRIAGED false positive — each was traced +# input -> sink in this codebase before being suppressed (bead sable-qsig). +# These rules are honored identically by the local engines and the remote +# `rafter run` backend, and suppressed findings remain recoverable in the +# `_suppressed` / `suppressed.json` output. Nothing here removes a finding from +# the audit trail; it only declassifies analyzed non-issues. +# +# NOT suppressed (fixed instead): dependency CVEs (bumped in package.json / +# pnpm-workspace overrides / pyproject) and the remote-tarball extraction path +# (zip-slip guarded in skill_remote.extract_npm_tarball). +version: "1" + +scan: + exclude_paths: + - "node_modules/" + - ".git/" + - "**/dist/" + - "**/__pycache__/**" # build artifacts (.pyc), not source + +ignore: + # ── Intentional demonstration / test inputs ─────────────────────────────── + # The demo app and the vulnerable-repo fixture EXIST to demonstrate detection; + # their "secrets" are deliberately fake (canonical AWS-docs example keys such + # as AKIAIOSFODNN7EXAMPLE) and their "vulnerabilities" are on purpose. + - paths: ["demo/**"] + reason: "Demo app: intentional FAKE secrets (canonical AWS-docs example keys, sk_test Stripe, an example JWT) used to showcase rafter detection. Not real credentials." + - paths: ["README.md", "outreach-drafts.md", ".github/workflows/test-comprehensive.yml", ".rafter.yml"] + reason: "Documentation / CI examples and this policy file reference the canonical AWS-docs example access key (a well-known public non-secret) to illustrate detection." + - paths: ["**/fixtures/**", "fixtures/**"] + reason: "Test fixtures: intentionally-vulnerable / fake-secret inputs that exercise the scanner (e.g. requests-without-timeout in fixtures/vulnerable-repo)." + - paths: ["**/tests/**", "**/test_*.py", "**/*.test.ts", "**/fixtures/**", ".github/fixtures/**"] + reason: "Test suites and fixtures embed fake secrets / deliberately-unsafe snippets as detection fixtures (this repo IS a secret scanner). Covers both the secret-pattern engine and the remote SAST rule ids (R-D577E etc.)." + + # ── Hardcoded-secret rule firing on non-credential string literals ───────── + - paths: ["python/rafter_cli/commands/agent.py", "python/rafter_cli/commands/hook.py"] + rules: ["R-D577E"] + reason: "Event-indicator labels and placeholders ('[SECRET]', '🔑', 'default'), not credentials. Verified: these are display strings in _EVENT_INDICATORS_AGENT." + + # ── XXE rule misfire (no XML parsing exists) ────────────────────────────── + - paths: ["python/**"] + rules: ["R-6675E"] + reason: "False positive: the Python codebase imports no XML parser (grep for xml/lxml/ElementTree is empty). Flagged lines are mkdir/read_text/for-loops. No XXE surface." + + # ── git/diff/path parsing mislabeled as URL or format validation ────────── + - paths: + - "python/rafter_cli/utils/git_diff.py" + - "python/rafter_cli/utils/git.py" + - "python/rafter_cli/commands/skill.py" + - "python/rafter_cli/commands/skill_remote.py" + - "python/rafter_cli/commands/brief.py" + - "python/rafter_cli/commands/agent.py" + - "python/rafter_cli/commands/agent_components.py" + - "python/rafter_cli/core/pattern_engine.py" + rules: ["R-3F913", "R-88B14"] + reason: "startswith('b/')/('diff --git')/('+') is git-diff parsing; endswith('.git') is git-URL handling. Neither is URL/path-traversal validation." + + # ── Timing-attack rule on a non-credential comparison ───────────────────── + - paths: ["python/rafter_cli/commands/notify.py"] + rules: ["R-51C60"] + reason: "Flagged `==` compares scan status strings ('completed'/'unknown'), not secrets. No credential comparison; timing channel is irrelevant." + + # ── Local-CLI filesystem operations (no external attacker in threat model) ─ + # This is a developer CLI that reads/writes config, skills, hooks and binaries + # at paths derived from the user's OWN $HOME, cwd and CLI arguments. There is + # no untrusted remote actor supplying these paths. The single genuine + # external-input path — extracting a downloaded remote skill tarball — is + # independently zip-slip-validated in skill_remote.extract_npm_tarball. + - paths: ["node/src/**", "vscode/src/**"] + rules: ["R-61603", "R-374DB"] + reason: "fs.*Sync / path.join on non-literal paths derived from the user's own $HOME/cwd/CLI args; not externally-tainted. Remote-tarball extraction is separately zip-slip-guarded." + + # ── Local audit-log / process internals (note-level rule misfires) ──────── + - paths: ["python/rafter_cli/core/audit_logger.py", "node/src/core/audit-logger.ts"] + rules: ["R-C66D8", "R-5DF7B"] + reason: "random.randbytes builds a log SESSION ID (not a security token); O_WRONLY|O_APPEND is correct for an append-only audit log." + - paths: ["python/rafter_cli/commands/agent.py"] + rules: ["R-93905"] + reason: "Style: re.sub on a precompiled pattern. No untrusted-substitution security impact." + + # ── Scanner regexes / RegExp constructor (self-inflicted ReDoS at most) ──── + # The CLI compiles its own maintained detection patterns plus user-supplied + # custom patterns; any catastrophic-backtracking risk is borne by the user + # scanning their own repo with their own pattern, not an external attacker. + - paths: ["node/src/core/**", "node/src/scanners/**", "vscode/src/secret-scanner.ts", "node/src/commands/issues/from-text.ts", "node/src/utils/git-diff.ts"] + rules: ["R-EFEEF", "R-8CBA2"] + reason: "RegExp built from maintained detection patterns / user-supplied custom patterns. ReDoS risk is self-inflicted (user scans own repo), not externally reachable." + + # ── Style/lint notes with no exploit path ───────────────────────────────── + - paths: ["python/**"] + rules: ["R-F8672", "R-0AB4A", "R-A01A0", "R-CA639", "R-5FEE9", "R-E617E"] + reason: "Style/robustness notes: urllib responses are read via .read(); broad except-pass on best-effort config loads; PATH-resolved trusted binaries (git/betterleaks) via binary-manager. No exploit path." + - paths: ["node/src/commands/skill/remote.ts"] + rules: ["R-BFD19"] + reason: "ESLint config note (@typescript-eslint/no-explicit-any plugin not resolved during the scan), not a security finding." diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4fc03..8f04209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.10] - 2026-06-28 + +### Changed + +- **Claude Code `PostToolUse` hook matcher narrowed from `.*` to `Bash|Write|Edit|MultiEdit`** (Node + Python, sable-h0ah). `rafter agent init --with-claude-code` (and `rafter agent enable claude-code.hooks`) previously registered the `rafter hook posttool` redaction hook with a catch-all `.*` matcher, so it fired after **every** Claude Code tool call — including `Read` and MCP tools, which never produce secrets to redact — adding latency to every operation. The matcher now targets only the tools whose output is worth scanning: shell output (`Bash`) and file writes (`Write`/`Edit`/`MultiEdit`). PreToolUse matchers are unchanged. Codex (`.*` PostToolUse) and Gemini (`.*` AfterTool) have the same broad-matcher latency issue and are tracked separately for platform-correct narrow matchers. + +### Fixed + +- **Hooks tolerate harness-appended flags** (#180). `rafter hook pretool` / `posttool` read their input from **stdin**, so they now ignore unknown options and extra positional arguments that an agent platform appends to the hook command — e.g. Claude Code adds `--hook-json `. Such extras are discarded rather than erroring; declared options like `--format` are still parsed normally. + +### Security + +- **Dependency CVE updates** (sable-qsig). Bumped vulnerable (mostly transitive) dependencies past their fixed versions via pnpm workspace overrides + a re-lock: axios 1.13.6→1.18.1, hono 4.12.8→4.12.27, tar→7.5.17, js-yaml→4.3.0, plus fast-uri, path-to-regexp, form-data, follow-redirects, ip-address, qs, and brace-expansion. Python direct-dep floors raised (`requests`, `urllib3`, `python-dotenv`); transitive Python bumps tracked separately. No CLI behavior change. +- **Hardened remote-skill tarball extraction** (sable-qsig). `rafter skill review` / install now extracts only regular files and directories from a fetched npm tarball, skipping symlink/hardlink/device members that a malicious archive could use to redirect a later write outside the destination — defense-in-depth atop the existing zip-slip path check. + ## [0.8.9] - 2026-06-20 ### Added diff --git a/SKILL.md b/SKILL.md index c7d6da9..2c2479e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -128,7 +128,7 @@ rafter agent exec "rm -rf /" # critical -- blocked When installed via `rafter agent init --with-claude-code`, Rafter registers hooks in `~/.claude/settings.json`: - **PreToolUse** hooks on `Bash` and `Write|Edit` tool calls. Every shell command is evaluated against the risk policy before execution. Dangerous commands are blocked or require approval transparently -- no manual invocation needed. -- **PostToolUse** hooks on all tool calls (`.*` matcher). Scans output for accidentally leaked secrets and redacts them. +- **PostToolUse** hooks on `Bash`, `Write`, `Edit`, and `MultiEdit` tool calls (`Bash|Write|Edit|MultiEdit` matcher). Scans command output and file writes for accidentally leaked secrets and redacts them. (Scoped to these tools so the hook doesn't fire on every `Read`/MCP call, which never produces secrets to redact and only added latency.) The hooks read JSON from stdin and write a `{"decision": "allow"}` or `{"decision": "deny", "reason": "..."}` response to stdout. This is the Claude Code hook protocol. @@ -142,7 +142,7 @@ Manual hook config (if not using `rafter agent init`): { "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "rafter hook pretool" }] } ], "PostToolUse": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "rafter hook posttool" }] } + { "matcher": "Bash|Write|Edit|MultiEdit", "hooks": [{ "type": "command", "command": "rafter hook posttool" }] } ] } } diff --git a/node/package.json b/node/package.json index 52525c3..7df888d 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@rafter-security/cli", - "version": "0.8.9", + "version": "0.8.10", "type": "module", "repository": { "type": "git", @@ -31,15 +31,15 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", - "axios": "^1.13.5", + "axios": "^1.16.0", "chalk": "^5.3.0", "chokidar": "^5.0.0", "commander": "^11.1.0", "dotenv": "^16.4.5", - "js-yaml": "^4.1.0", + "js-yaml": "^4.2.0", "minimatch": "^10.2.4", "ora": "^7.0.1", - "tar": "^7.5.10" + "tar": "^7.5.16" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/node/resources/rafter-security-skill.md b/node/resources/rafter-security-skill.md index b5c19e0..7934944 100644 --- a/node/resources/rafter-security-skill.md +++ b/node/resources/rafter-security-skill.md @@ -1,7 +1,7 @@ --- name: rafter-security description: Security toolkit for AI workflows. Use when scanning code or repos for vulnerabilities, auditing third-party skills/MCPs/agent configs before installing, evaluating shell commands before running them, or generating secure design questions for new features. Provides `rafter run` (remote SAST + SCA, needs RAFTER_API_KEY), `rafter secrets` (offline secrets-only), `rafter agent exec --dry-run` (command-risk classification), and `rafter skill review`. -version: 0.8.9 +version: 0.8.10 homepage: https://rafter.so metadata: openclaw: diff --git a/node/src/commands/agent/components.ts b/node/src/commands/agent/components.ts index 897cc8f..aecf13a 100644 --- a/node/src/commands/agent/components.ts +++ b/node/src/commands/agent/components.ts @@ -174,7 +174,9 @@ function claudeCodeHooks(): ComponentSpec { { matcher: "Bash", hooks: [pre] }, { matcher: "Write|Edit", hooks: [pre] }, ); - settings.hooks.PostToolUse.push({ matcher: ".*", hooks: [post] }); + // Narrow to tools that produce scannable output (shell output + file + // writes); avoids firing posttool on every Read/MCP call (latency). + settings.hooks.PostToolUse.push({ matcher: "Bash|Write|Edit|MultiEdit", hooks: [post] }); writeJson(settingsPath, settings); }, diff --git a/node/src/commands/agent/init.ts b/node/src/commands/agent/init.ts index 7d482e3..24c61c2 100644 --- a/node/src/commands/agent/init.ts +++ b/node/src/commands/agent/init.ts @@ -322,8 +322,11 @@ function installClaudeCodeHooks(root: string): void { { matcher: "Bash", hooks: [preHook] }, { matcher: "Write|Edit", hooks: [preHook] }, ); + // Narrow to tools that produce scannable output (shell output + file + // writes). Firing posttool on every tool — including Read and MCP calls — + // added latency to operations that never produce secrets to redact. settings.hooks.PostToolUse.push( - { matcher: ".*", hooks: [postHook] }, + { matcher: "Bash|Write|Edit|MultiEdit", hooks: [postHook] }, ); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8"); diff --git a/node/src/commands/hook/posttool.ts b/node/src/commands/hook/posttool.ts index 50965a7..5e483a5 100644 --- a/node/src/commands/hook/posttool.ts +++ b/node/src/commands/hook/posttool.ts @@ -23,6 +23,11 @@ interface PostToolOutput { export function createHookPosttoolCommand(): Command { return new Command("posttool") .description("PostToolUse hook handler (reads stdin, redacts secrets in output, writes JSON to stdout)") + // Tolerate extra flags/args the host harness appends to the hook command + // (e.g. Claude Code adds `--hook-json `). Hook input comes from stdin, + // so anything else is unused — discard it instead of erroring out. + .allowUnknownOption() + .allowExcessArguments() .option("--format ", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude") .action(async (opts) => { const format = (opts.format || "claude") as HookFormat; diff --git a/node/src/commands/hook/pretool.ts b/node/src/commands/hook/pretool.ts index 060354b..68024d8 100644 --- a/node/src/commands/hook/pretool.ts +++ b/node/src/commands/hook/pretool.ts @@ -95,6 +95,11 @@ function formatApprovalMessage(command: string, evaluation: CommandEvaluation): export function createHookPretoolCommand(): Command { return new Command("pretool") .description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)") + // Tolerate extra flags/args the host harness appends to the hook command + // (e.g. Claude Code adds `--hook-json `). Hook input comes from stdin, + // so anything else is unused — discard it instead of erroring out. + .allowUnknownOption() + .allowExcessArguments() .option("--format ", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude") .action(async (opts) => { const format = (opts.format || "claude") as HookFormat; diff --git a/node/src/core/command-interceptor.ts b/node/src/core/command-interceptor.ts index 2f90900..2eb8101 100644 --- a/node/src/core/command-interceptor.ts +++ b/node/src/core/command-interceptor.ts @@ -1,6 +1,6 @@ import { ConfigManager } from "./config-manager.js"; import { AuditLogger } from "./audit-logger.js"; -import { assessCommandRisk, CommandRiskLevel } from "./risk-rules.js"; +import { assessCommandRisk, matchedCriticalPattern, CommandRiskLevel } from "./risk-rules.js"; export type { CommandRiskLevel } from "./risk-rules.js"; @@ -26,14 +26,42 @@ export class CommandInterceptor { * Evaluate if a command should be allowed */ evaluate(command: string): CommandEvaluation { + const riskLevel = this.assessRisk(command); + + // Unconditional hard-block: catastrophic destructive commands (rm -rf /, + // fork bombs, disk wipes, mkfs, …) are NEVER allowed, regardless of the + // configured policy — or its absence. Security must not depend on a policy + // being present (the default config may be missing one) or on the chosen + // mode (even allow-all / a custom deny-list cannot opt out of these). + if (riskLevel === "critical") { + return { + command, + riskLevel, + allowed: false, + requiresApproval: false, + reason: "Matches built-in blocked pattern (critical destructive command)", + matchedPattern: matchedCriticalPattern(command) ?? "builtin:critical-destructive" + }; + } + const cfg = this.config.loadWithPolicy(); const policy = cfg.agent?.commandPolicy; if (!policy) { - // No policy configured, allow by default but still assess risk + // No policy configured — fall back to safe built-in defaults rather than + // allow-all: high-risk commands still require approval. + if (riskLevel === "high") { + return { + command, + riskLevel, + allowed: false, + requiresApproval: true, + reason: "High risk command requires approval" + }; + } return { command, - riskLevel: this.assessRisk(command), + riskLevel, allowed: true, requiresApproval: false }; diff --git a/node/src/core/risk-rules.ts b/node/src/core/risk-rules.ts index 81bdfa9..9691d5b 100644 --- a/node/src/core/risk-rules.ts +++ b/node/src/core/risk-rules.ts @@ -96,3 +96,17 @@ export function assessCommandRisk(command: string): CommandRiskLevel { } return "low"; } + +/** + * Return the source of the first CRITICAL pattern matching the command, or null. + * Mirrors assessCommandRisk's lowercasing. Intended to be called only once a + * command is already classified "critical" (safe-prefix exclusion is handled by + * assessCommandRisk), to surface *which* built-in rule matched. + */ +export function matchedCriticalPattern(command: string): string | null { + const cmd = command.toLowerCase().trim(); + for (const pattern of CRITICAL_PATTERNS) { + if (pattern.test(cmd)) return pattern.source; + } + return null; +} diff --git a/node/tests/agent-compatibility.test.ts b/node/tests/agent-compatibility.test.ts index 185b213..a7a7dd5 100644 --- a/node/tests/agent-compatibility.test.ts +++ b/node/tests/agent-compatibility.test.ts @@ -727,7 +727,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => { { matcher: "Write|Edit", hooks: [{ type: "command", command: "rafter hook pretool" }] }, ], PostToolUse: [ - { matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] }, + { matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] }, ], }, }; @@ -739,7 +739,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => { expect(loaded.hooks.PostToolUse).toHaveLength(1); expect(loaded.hooks.PreToolUse[0].matcher).toBe("Bash"); expect(loaded.hooks.PreToolUse[1].matcher).toBe("Write|Edit"); - expect(loaded.hooks.PostToolUse[0].matcher).toBe(".*"); + expect(loaded.hooks.PostToolUse[0].matcher).toBe("Bash|Write|Edit|MultiEdit"); }); it("preserves existing non-rafter hooks when installing", () => { @@ -776,7 +776,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => { { matcher: "Write|Edit", hooks: [{ type: "command", command: "rafter hook pretool" }] }, ); settings.hooks.PostToolUse.push( - { matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] }, + { matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] }, ); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); @@ -800,7 +800,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => { { matcher: "Bash", hooks: [{ type: "command", command: "rafter hook pretool" }] }, ], PostToolUse: [ - { matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] }, + { matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] }, ], }, }; @@ -827,7 +827,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => { { matcher: "Write|Edit", hooks: [{ type: "command", command: "rafter hook pretool" }] }, ); settings.hooks.PostToolUse.push( - { matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] }, + { matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] }, ); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); @@ -886,7 +886,7 @@ describe("Agent Init: Environment Detection & Hook/Skill Installation", () => { { matcher: "Bash", hooks: [{ type: "command", command: "rafter hook pretool" }] }, ], PostToolUse: [ - { matcher: ".*", hooks: [{ type: "command", command: "rafter hook posttool" }] }, + { matcher: "Bash|Write|Edit|MultiEdit", hooks: [{ type: "command", command: "rafter hook posttool" }] }, ], }, }, null, 2) diff --git a/node/tests/command-interceptor-policy.test.ts b/node/tests/command-interceptor-policy.test.ts index 908bfdc..7df4bcb 100644 --- a/node/tests/command-interceptor-policy.test.ts +++ b/node/tests/command-interceptor-policy.test.ts @@ -41,12 +41,21 @@ describe("CommandInterceptor — Policy modes", () => { // ── No policy (agent.commandPolicy undefined) ─────────────────────── describe("No policy configured", () => { - it("should allow any command and still assess risk", () => { + it("should hard-block critical destructive commands even with no policy", () => { stubPolicy(interceptor, null); const result = interceptor.evaluate("rm -rf /"); - expect(result.allowed).toBe(true); + expect(result.allowed).toBe(false); expect(result.requiresApproval).toBe(false); expect(result.riskLevel).toBe("critical"); + expect(result.matchedPattern).toBeDefined(); + }); + + it("should require approval for high-risk commands even with no policy", () => { + stubPolicy(interceptor, null); + const result = interceptor.evaluate("rm -rf node_modules"); + expect(result.allowed).toBe(false); + expect(result.requiresApproval).toBe(true); + expect(result.riskLevel).toBe("high"); }); it("should return low risk for safe command", () => { @@ -126,7 +135,7 @@ describe("CommandInterceptor — Policy modes", () => { expect(result.reason).toContain("High risk"); }); - it("should require approval for critical-risk commands", () => { + it("should hard-block critical-risk commands (never merely require approval)", () => { stubPolicy(interceptor, { mode: "approve-dangerous", blockedPatterns: [], @@ -134,7 +143,7 @@ describe("CommandInterceptor — Policy modes", () => { }); const result = interceptor.evaluate("mkfs.ext4 /dev/sda1"); expect(result.allowed).toBe(false); - expect(result.requiresApproval).toBe(true); + expect(result.requiresApproval).toBe(false); expect(result.riskLevel).toBe("critical"); }); @@ -190,14 +199,14 @@ describe("CommandInterceptor — Policy modes", () => { // ── allow-all mode ────────────────────────────────────────────────── describe("allow-all mode", () => { - it("should allow even critical commands", () => { + it("should still hard-block critical destructive commands (allow-all cannot opt out)", () => { stubPolicy(interceptor, { mode: "allow-all", blockedPatterns: [], requireApproval: [], }); const result = interceptor.evaluate("rm -rf /"); - expect(result.allowed).toBe(true); + expect(result.allowed).toBe(false); expect(result.requiresApproval).toBe(false); expect(result.riskLevel).toBe("critical"); }); @@ -341,18 +350,20 @@ describe("CommandInterceptor — Policy modes", () => { // ── Policy override replaces defaults ─────────────────────────────── describe("Policy overrides defaults", () => { - it("custom blockedPatterns replace default blocked patterns", () => { - // With custom blocked patterns that don't include "rm -rf /", - // the default blocked patterns should NOT apply + it("built-in critical hard-block applies even when custom blockedPatterns omit it", () => { + // Custom blocked patterns replace the policy defaults, but the built-in + // critical-destructive hard-block is unconditional and still applies — + // "rm -rf /" can never be allowed through, whatever the policy says. stubPolicy(interceptor, { mode: "deny-list", blockedPatterns: ["only-this-is-blocked"], requireApproval: [], }); - // "rm -rf /" would be blocked by defaults, but custom replaces them const result = interceptor.evaluate("rm -rf /"); - expect(result.allowed).toBe(true); // not in custom blockedPatterns + expect(result.allowed).toBe(false); + expect(result.requiresApproval).toBe(false); + expect(result.riskLevel).toBe("critical"); }); it("custom requireApproval replaces default approval patterns", () => { diff --git a/node/tests/hook-extra-args.test.ts b/node/tests/hook-extra-args.test.ts new file mode 100644 index 0000000..60c1b31 --- /dev/null +++ b/node/tests/hook-extra-args.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { spawnSync } from "child_process"; +import path from "path"; + +// Regression: the host harness may append extra flags/args to the hook command. +// Claude Code appends `--hook-json `; hook input comes from stdin, so the +// CLI must tolerate (discard) the extra flag instead of erroring (#180). + +const CLI_ENTRY = path.join(path.resolve(__dirname, ".."), "dist", "index.js"); +const PRETOOL_IN = JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls" } }); +const POSTTOOL_IN = JSON.stringify({ tool_name: "Bash", tool_response: { output: "ok" } }); + +function runHook(args: string): { code: number; out: any } { + const r = spawnSync(`node ${CLI_ENTRY} ${args}`, { + input: args.includes("posttool") ? POSTTOOL_IN : PRETOOL_IN, + encoding: "utf-8", + shell: true, + timeout: 30_000, + }); + return { code: r.status ?? -1, out: JSON.parse(r.stdout || "{}") }; +} + +describe("hook commands tolerate harness-appended flags (#180)", () => { + it("pretool accepts --hook-json and still returns a decision", () => { + const { code, out } = runHook("hook pretool --hook-json '{}'"); + expect(code).toBe(0); + expect(out.hookSpecificOutput?.permissionDecision).toBe("allow"); + }); + + it("posttool accepts --hook-json", () => { + const { code, out } = runHook("hook posttool --hook-json '{\"x\":1}'"); + expect(code).toBe(0); + expect(out.hookSpecificOutput?.hookEventName).toBe("PostToolUse"); + }); + + it("a real option (--format) is still honored, not swallowed", () => { + // gemini "allow" emits an empty object, distinct from the claude shape. + const r = spawnSync(`node ${CLI_ENTRY} hook pretool --format gemini --hook-json '{}'`, { + input: PRETOOL_IN, encoding: "utf-8", shell: true, timeout: 30_000, + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("{}"); + }); + + it("still works with no extra flag (unchanged behavior)", () => { + const { code, out } = runHook("hook pretool"); + expect(code).toBe(0); + expect(out.hookSpecificOutput?.permissionDecision).toBe("allow"); + }); +}); diff --git a/node/tests/platform-integration.test.ts b/node/tests/platform-integration.test.ts index b02be58..8e2af9d 100644 --- a/node/tests/platform-integration.test.ts +++ b/node/tests/platform-integration.test.ts @@ -1171,9 +1171,10 @@ describe("Platform Integration — MCP Installs via CLI", () => { ); expect(postCommands).toContain("rafter hook posttool"); - // PostToolUse should have catch-all matcher + // PostToolUse matcher is scoped to tools that produce scannable output + // (shell output + file writes) rather than firing on every tool call. const postMatchers = settings.hooks.PostToolUse.map((e: any) => e.matcher); - expect(postMatchers).toContain(".*"); + expect(postMatchers).toContain("Bash|Write|Edit|MultiEdit"); }); it("should install skills to ~/.claude/skills/", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ea4dde..e4de940 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,16 @@ settings: overrides: express-rate-limit: '>=8.2.2' - hono: '>=4.12.7' - '@hono/node-server': '>=1.19.10' + hono: '>=4.12.25' + '@hono/node-server': '>=1.19.13' ajv: '>=8.18.0' + fast-uri: '>=3.1.2' + path-to-regexp: '>=8.4.0' + form-data: '>=4.0.6' + follow-redirects: '>=1.16.0' + ip-address: '>=10.1.1' + qs: '>=6.15.2' + brace-expansion: '>=5.0.6' importers: @@ -18,8 +25,8 @@ importers: specifier: ^1.27.1 version: 1.27.1(zod@3.25.76) axios: - specifier: ^1.13.5 - version: 1.13.6 + specifier: ^1.16.0 + version: 1.18.1 chalk: specifier: ^5.3.0 version: 5.4.1 @@ -33,8 +40,8 @@ importers: specifier: ^16.4.5 version: 16.6.1 js-yaml: - specifier: ^4.1.0 - version: 4.1.1 + specifier: ^4.2.0 + version: 4.3.0 minimatch: specifier: ^10.2.4 version: 10.2.4 @@ -42,8 +49,8 @@ importers: specifier: ^7.0.1 version: 7.0.1 tar: - specifier: ^7.5.10 - version: 7.5.11 + specifier: ^7.5.16 + version: 7.5.17 devDependencies: '@types/js-yaml': specifier: ^4.0.9 @@ -543,11 +550,11 @@ packages: cpu: [x64] os: [win32] - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.6': + resolution: {integrity: sha512-7DeRlKG57JDBNZ5Qj2jwVdgwQy4b0tLubRLl3zCf91/rCf9i7p1V5FtW/yWibm1uUHE493ts9ZXH/7g/LQWl+g==} + engines: {node: '>=20'} peerDependencies: - hono: '>=4.12.7' + hono: '>=4.12.25' '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} @@ -758,6 +765,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -783,8 +794,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} @@ -800,8 +811,8 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} buffer@6.0.3: @@ -994,8 +1005,8 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@4.0.0: + resolution: {integrity: sha512-l90y339r2DkZs/ldcWQXcwTjkbp/NbuJDGYoQ3awBgaT3GXOFkm3OkVpz6Z86TywYcya0eVP2r1kTV90f3krGQ==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1010,8 +1021,8 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -1019,8 +1030,8 @@ packages: debug: optional: true - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} forwarded@0.2.0: @@ -1062,18 +1073,22 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.12.8: - resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1084,8 +1099,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -1109,8 +1124,8 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true json-schema-traverse@1.0.0: @@ -1216,8 +1231,8 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1241,11 +1256,12 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} range-parser@1.2.1: @@ -1309,8 +1325,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -1321,8 +1337,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -1360,8 +1376,8 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} - tar@7.5.11: - resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} + tar@7.5.17: + resolution: {integrity: sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==} engines: {node: '>=18'} tinybench@2.9.0: @@ -1744,9 +1760,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@hono/node-server@1.19.11(hono@4.12.8)': + '@hono/node-server@2.0.6(hono@4.12.27)': dependencies: - hono: 4.12.8 + hono: 4.12.27 '@isaacs/fs-minipass@4.0.1': dependencies: @@ -1756,7 +1772,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) + '@hono/node-server': 2.0.6(hono@4.12.27) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -1766,7 +1782,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.0(express@5.2.1) - hono: 4.12.8 + hono: 4.12.27 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -1916,6 +1932,12 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -1923,7 +1945,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 4.0.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1935,13 +1957,15 @@ snapshots: asynckit@0.4.0: {} - axios@1.13.6: + axios@1.18.1: dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color balanced-match@4.0.4: {} @@ -1961,13 +1985,13 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.14.2 + qs: 6.15.3 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: - supports-color - brace-expansion@5.0.4: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2074,7 +2098,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 esbuild@0.24.2: optionalDependencies: @@ -2181,7 +2205,7 @@ snapshots: express-rate-limit@8.3.0(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.1.0 + ip-address: 10.2.0 express@5.2.1: dependencies: @@ -2205,7 +2229,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.2 + qs: 6.15.3 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -2218,7 +2242,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-uri@3.1.0: {} + fast-uri@4.0.0: {} fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -2226,7 +2250,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.1 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -2235,14 +2259,14 @@ snapshots: transitivePeerDependencies: - supports-color - follow-redirects@1.15.11: {} + follow-redirects@1.16.0: {} - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 forwarded@0.2.0: {} @@ -2264,7 +2288,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-proto@1.0.1: @@ -2284,11 +2308,11 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 - hono@4.12.8: {} + hono@4.12.27: {} http-errors@2.0.1: dependencies: @@ -2298,6 +2322,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -2306,7 +2337,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.1.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -2320,7 +2351,7 @@ snapshots: jose@6.1.3: {} - js-yaml@4.1.1: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -2359,7 +2390,7 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.6 minipass@7.1.2: {} @@ -2407,7 +2438,7 @@ snapshots: path-key@3.1.1: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -2428,11 +2459,12 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} - qs@6.14.2: + qs@6.15.3: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 range-parser@1.2.1: {} @@ -2493,11 +2525,11 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -2538,7 +2570,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -2558,11 +2590,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -2596,7 +2628,7 @@ snapshots: dependencies: ansi-regex: 6.1.0 - tar@7.5.11: + tar@7.5.17: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9cdfc83..328bcb8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,14 @@ onlyBuiltDependencies: overrides: express-rate-limit: '>=8.2.2' - hono: '>=4.12.7' - '@hono/node-server': '>=1.19.10' + hono: '>=4.12.25' + '@hono/node-server': '>=1.19.13' ajv: '>=8.18.0' + # CVE remediation (bead sable-qsig) — force fixed floors on transitive deps. + fast-uri: '>=3.1.2' + path-to-regexp: '>=8.4.0' + form-data: '>=4.0.6' + follow-redirects: '>=1.16.0' + ip-address: '>=10.1.1' + qs: '>=6.15.2' + brace-expansion: '>=5.0.6' diff --git a/python/pyproject.toml b/python/pyproject.toml index 695f7f1..99654fd 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rafter-cli" -version = "0.8.9" +version = "0.8.10" description = "Rafter CLI — the default security agent for AI workflows. Free for individuals and open source." authors = ["Rafter Team "] license = "MIT" @@ -11,9 +11,9 @@ python = ">=3.10,<4.0" typer = "^0.15.0" click = ">=8.0.0,<9.0.0" rich = "^13.7.1" -python-dotenv = "^1.0.1" -requests = "^2.31.0" -urllib3 = ">=2.6.0,<3" +python-dotenv = "^1.2.2" +requests = "^2.33.0" +urllib3 = ">=2.7.0,<3" pyyaml = "^6.0.1" mcp = "^1.9.0" watchdog = "^4.0.0" diff --git a/python/rafter_cli/commands/agent.py b/python/rafter_cli/commands/agent.py index a6886be..cd65f55 100644 --- a/python/rafter_cli/commands/agent.py +++ b/python/rafter_cli/commands/agent.py @@ -205,8 +205,11 @@ def _install_claude_code_hooks(root: Path) -> None: {"matcher": "Bash", "hooks": [pre_hook]}, {"matcher": "Write|Edit", "hooks": [pre_hook]}, ]) + # Narrow to tools that produce scannable output (shell output + file + # writes). Firing posttool on every tool — including Read and MCP calls — + # added latency to operations that never produce secrets to redact. settings["hooks"]["PostToolUse"].extend([ - {"matcher": ".*", "hooks": [post_hook]}, + {"matcher": "Bash|Write|Edit|MultiEdit", "hooks": [post_hook]}, ]) settings_path.write_text(json.dumps(settings, indent=2) + "\n") diff --git a/python/rafter_cli/commands/agent_components.py b/python/rafter_cli/commands/agent_components.py index c3820c6..a781aa6 100644 --- a/python/rafter_cli/commands/agent_components.py +++ b/python/rafter_cli/commands/agent_components.py @@ -186,7 +186,9 @@ def install() -> None: {"matcher": "Bash", "hooks": [pre]}, {"matcher": "Write|Edit", "hooks": [pre]}, ]) - hooks["PostToolUse"].append({"matcher": ".*", "hooks": [post]}) + # Narrow to tools that produce scannable output (shell output + file + # writes); avoids firing posttool on every Read/MCP call (latency). + hooks["PostToolUse"].append({"matcher": "Bash|Write|Edit|MultiEdit", "hooks": [post]}) _write_json(settings_path, s) def uninstall() -> None: diff --git a/python/rafter_cli/commands/hook.py b/python/rafter_cli/commands/hook.py index 026f296..09c059f 100644 --- a/python/rafter_cli/commands/hook.py +++ b/python/rafter_cli/commands/hook.py @@ -13,7 +13,15 @@ from ..core.command_interceptor import CommandInterceptor from ..scanners.regex_scanner import RegexScanner -hook_app = typer.Typer(name="hook", help="Hook handlers for agent platform integration", no_args_is_help=True) +# allow_extra_args / ignore_unknown_options: tolerate extra flags/args the host +# harness appends to the hook command (e.g. Claude Code adds `--hook-json `). +# Hook input comes from stdin, so anything else is unused — discard, don't error. +hook_app = typer.Typer( + name="hook", + help="Hook handlers for agent platform integration", + no_args_is_help=True, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) _RISK_LABELS = { "critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW", @@ -401,7 +409,10 @@ def _evaluate_write(tool_input: dict) -> dict: return {"decision": "deny", "reason": f"Secret detected in {file_path}: {', '.join(names)}"} -@hook_app.command("pretool") +@hook_app.command( + "pretool", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) def pretool( format: str = typer.Option("claude", "--format", help="Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf"), ): @@ -449,7 +460,10 @@ def pretool( _write_pretool_decision({"decision": "allow"}, format) -@hook_app.command("posttool") +@hook_app.command( + "posttool", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) def posttool( format: str = typer.Option("claude", "--format", help="Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf"), ): diff --git a/python/rafter_cli/commands/skill_remote.py b/python/rafter_cli/commands/skill_remote.py index 91a3048..f1068ad 100644 --- a/python/rafter_cli/commands/skill_remote.py +++ b/python/rafter_cli/commands/skill_remote.py @@ -346,6 +346,11 @@ def extract_npm_tarball(tgz_file: Path, dest_dir: Path) -> None: # Reject path traversal. if member.name.startswith("/") or ".." in member.name.split("/"): continue + # Defense-in-depth: only extract regular files and directories. + # Skip symlinks/hardlinks/devices, which a malicious archive could + # use to redirect a later member's write outside dest_dir. + if not (member.isfile() or member.isdir()): + continue members.append(member) tf.extractall(dest_dir, members=members) # noqa: S202 diff --git a/python/rafter_cli/core/command_interceptor.py b/python/rafter_cli/core/command_interceptor.py index 7ed0254..26058d2 100644 --- a/python/rafter_cli/core/command_interceptor.py +++ b/python/rafter_cli/core/command_interceptor.py @@ -6,7 +6,7 @@ from .audit_logger import AuditLogger from .config_manager import ConfigManager -from .risk_rules import assess_command_risk +from .risk_rules import assess_command_risk, match_critical_pattern @dataclass @@ -25,6 +25,21 @@ def __init__(self) -> None: self._audit = AuditLogger() def evaluate(self, command: str) -> CommandEvaluation: + # Unconditional hard-block: catastrophic destructive commands (rm -rf /, + # fork bombs, disk wipes, mkfs, …) are NEVER allowed, regardless of the + # configured policy — or its absence. Security must not depend on a + # policy being present or on the chosen mode (even allow-all / a custom + # deny-list cannot opt out of these). + if assess_command_risk(command) == "critical": + return CommandEvaluation( + command=command, + risk_level="critical", + allowed=False, + requires_approval=False, + reason="Matches built-in blocked pattern (critical destructive command)", + matched_pattern=match_critical_pattern(command) or "builtin:critical-destructive", + ) + cfg = self._config.load_with_policy() policy = cfg.agent.command_policy diff --git a/python/rafter_cli/core/risk_rules.py b/python/rafter_cli/core/risk_rules.py index 1409864..1c69667 100644 --- a/python/rafter_cli/core/risk_rules.py +++ b/python/rafter_cli/core/risk_rules.py @@ -85,3 +85,16 @@ def assess_command_risk(command: str) -> str: if re.search(p, command, re.IGNORECASE): return "medium" return "low" + + +def match_critical_pattern(command: str) -> str | None: + """Return the first CRITICAL pattern matching the command, or None. + + Mirrors assess_command_risk's matching. Intended to be called only once a + command is already classified "critical" (safe-prefix exclusion is handled + by assess_command_risk), to surface *which* built-in rule matched. + """ + for p in CRITICAL_PATTERNS: + if re.search(p, command, re.IGNORECASE): + return p + return None diff --git a/python/rafter_cli/resources/rafter-security-skill.md b/python/rafter_cli/resources/rafter-security-skill.md index b5c19e0..7934944 100644 --- a/python/rafter_cli/resources/rafter-security-skill.md +++ b/python/rafter_cli/resources/rafter-security-skill.md @@ -1,7 +1,7 @@ --- name: rafter-security description: Security toolkit for AI workflows. Use when scanning code or repos for vulnerabilities, auditing third-party skills/MCPs/agent configs before installing, evaluating shell commands before running them, or generating secure design questions for new features. Provides `rafter run` (remote SAST + SCA, needs RAFTER_API_KEY), `rafter secrets` (offline secrets-only), `rafter agent exec --dry-run` (command-risk classification), and `rafter skill review`. -version: 0.8.9 +version: 0.8.10 homepage: https://rafter.so metadata: openclaw: diff --git a/python/tests/test_agent_init.py b/python/tests/test_agent_init.py index b59c733..ac9b0e5 100644 --- a/python/tests/test_agent_init.py +++ b/python/tests/test_agent_init.py @@ -166,6 +166,12 @@ def test_creates_settings_from_scratch(self, tmp_path, monkeypatch): assert "Bash" in matchers assert "Write|Edit" in matchers + # PostToolUse is scoped to tools that produce scannable output (shell + # output + file writes), not every tool call — avoids firing posttool + # on Read/MCP calls that never produce secrets to redact. + post_matchers = [e["matcher"] for e in settings["hooks"]["PostToolUse"]] + assert post_matchers == ["Bash|Write|Edit|MultiEdit"] + def test_preserves_existing_hooks(self, tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) claude_dir = tmp_path / ".claude" diff --git a/python/tests/test_command_interceptor_policy.py b/python/tests/test_command_interceptor_policy.py index 3e87804..9cb10ef 100644 --- a/python/tests/test_command_interceptor_policy.py +++ b/python/tests/test_command_interceptor_policy.py @@ -155,13 +155,14 @@ def test_high_risk_requires_approval(self): assert result.risk_level == "high" assert "high risk" in (result.reason or "").lower() - def test_critical_risk_requires_approval(self): + def test_critical_risk_hard_blocked(self): + # Critical destructive commands are hard-blocked, never merely approval. result = _eval_with_policy( "mkfs.ext4 /dev/sda1", mode="approve-dangerous", ) assert not result.allowed - assert result.requires_approval + assert not result.requires_approval assert result.risk_level == "critical" def test_medium_risk_allowed(self): @@ -203,12 +204,13 @@ def test_approval_pattern_checked_before_risk(self): class TestAllowAllMode: - def test_critical_command_allowed(self): + def test_critical_command_still_hard_blocked(self): + # allow-all cannot opt out of the built-in critical hard-block. result = _eval_with_policy( "rm -rf /", mode="allow-all", ) - assert result.allowed + assert not result.allowed assert not result.requires_approval assert result.risk_level == "critical" @@ -340,15 +342,18 @@ def test_empty_approval_with_approve_dangerous_uses_risk(self): class TestPolicyOverrides: - def test_custom_blocked_replaces_defaults(self): + def test_builtin_critical_block_survives_custom_blocked(self): + # Custom blocked patterns replace the policy defaults, but the built-in + # critical-destructive hard-block is unconditional and still applies. result = _eval_with_policy( "rm -rf /", mode="deny-list", blocked_patterns=["only-this-is-blocked"], require_approval=[], ) - # "rm -rf /" is NOT in the custom blocked list - assert result.allowed + assert not result.allowed + assert not result.requires_approval + assert result.risk_level == "critical" def test_custom_approval_replaces_defaults(self): result = _eval_with_policy( diff --git a/python/tests/test_hook_extra_args.py b/python/tests/test_hook_extra_args.py new file mode 100644 index 0000000..038336b --- /dev/null +++ b/python/tests/test_hook_extra_args.py @@ -0,0 +1,46 @@ +"""Regression: hook subcommands must tolerate harness-appended flags/args. + +Claude Code appends `--hook-json ` to the hook command; hook input comes +from stdin, so the extra flag must be discarded, not rejected (#180). The +context_settings must live on the SUBCOMMAND (pretool/posttool), not only the +hook_app group — a group-level setting does not reach subcommand parsing. +""" +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from rafter_cli.commands.hook import hook_app + +runner = CliRunner() + +_PRETOOL_IN = '{"tool_name":"Bash","tool_input":{"command":"ls"}}' +_POSTTOOL_IN = '{"tool_name":"Bash","tool_response":{"output":"ok"}}' + + +def test_pretool_tolerates_hook_json(): + r = runner.invoke(hook_app, ["pretool", "--hook-json", "{}"], input=_PRETOOL_IN) + assert r.exit_code == 0, r.output + assert json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"] == "allow" + + +def test_posttool_tolerates_hook_json(): + r = runner.invoke(hook_app, ["posttool", "--hook-json", '{"x":1}'], input=_POSTTOOL_IN) + assert r.exit_code == 0, r.output + assert json.loads(r.stdout)["hookSpecificOutput"]["hookEventName"] == "PostToolUse" + + +def test_real_format_option_still_honored(): + # gemini "allow" emits an empty object — proves --format wasn't swallowed. + r = runner.invoke( + hook_app, ["pretool", "--format", "gemini", "--hook-json", "{}"], input=_PRETOOL_IN + ) + assert r.exit_code == 0, r.output + assert r.stdout.strip() == "{}" + + +def test_no_extra_flag_unchanged(): + r = runner.invoke(hook_app, ["pretool"], input=_PRETOOL_IN) + assert r.exit_code == 0, r.output + assert json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"] == "allow" diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index c2aaed1..0ee0ef1 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -945,6 +945,8 @@ On a `git commit` / `git push` (and on `Write`/`Edit`), the hook scans for secre **Bounded stdin read.** Both `hook pretool` and `hook posttool` bound their stdin read so a host that opens the hook's stdin but never writes/closes it (no EOF) cannot wedge the hook: after the bound elapses the hook reads whatever arrived (typically nothing), fails open (`allow` / no-op redaction), and the process **exits** — it does not merely emit a decision and keep running. The bound is **5000 ms** by default and is overridable via `RAFTER_HOOK_STDIN_TIMEOUT_MS` (positive integer milliseconds; non-positive or unparseable values fall back to the default). Both implementations honor the same env var identically. +**Tolerates harness-appended flags.** Hook input arrives on **stdin**, so both subcommands ignore unknown options and extra positional args that an agent platform appends to the hook command — e.g. Claude Code adds `--hook-json `. Such extras are discarded (the hook never errors on them); declared options like `--format` are still parsed normally. + ### rafter hook posttool [OPTIONS] PostToolUse hook handler. Reads tool output from stdin, redacts any secrets found, and writes JSON to stdout. diff --git a/shared-docs/PLATFORM_PARITY_AUDIT.md b/shared-docs/PLATFORM_PARITY_AUDIT.md index e38559b..5eef791 100644 --- a/shared-docs/PLATFORM_PARITY_AUDIT.md +++ b/shared-docs/PLATFORM_PARITY_AUDIT.md @@ -46,7 +46,7 @@ Python mirrors live at the same callsites in `python/rafter_cli/commands/agent.p ### Claude Code — gold standard -What we ship: skills (`.claude/skills//SKILL.md`), sub-agent (`.claude/agents/rafter.md`, rf-q7j), hooks (PreToolUse Bash + Write|Edit, PostToolUse `.*`), MCP server, CLAUDE.md instruction block. +What we ship: skills (`.claude/skills//SKILL.md`), sub-agent (`.claude/agents/rafter.md`, rf-q7j), hooks (PreToolUse Bash + Write|Edit, PostToolUse `Bash|Write|Edit|MultiEdit`), MCP server, CLAUDE.md instruction block. Verify check: presence of `rafter hook pretool` in `~/.claude/settings.json` PreToolUse array. Real test of whether hooks fire is implicit — they do, because Claude Code is what we built against.