feat: action-based policy API + auto-memory + safe-filter expansion (v1.5)#8
Open
brycehans wants to merge 25 commits into
Open
feat: action-based policy API + auto-memory + safe-filter expansion (v1.5)#8brycehans wants to merge 25 commits into
brycehans wants to merge 25 commits into
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add the plumbing the auto-memory system needs and broaden two read-only policies to cover paths outside the project root. - deny-writes-outside-project: allow writes under ~/.claude/projects/<encoded>/memory/<file> so Claude can persist MEMORY.md and per-memory files outside the project root. - allow-memory-crud: permit Read/Write/Edit on those same memory paths, plus `rm <single-file>` via Bash (no -r/-f; memory is a flat dir). - allow-date: permit `date` and && chains of date for reading/formatting time, rejecting any form that sets the system clock. - allow-toolgate-test: permit `toolgate test ...` (dry-run policy check, never executes the underlying tool). - Rename allow-ls-in-project → allow-ls and broaden it to any path with no dot-prefixed segments (so `ls /tmp`, `ls ~/Downloads` work, but `ls ~/.ssh` and `ls .git` are still blocked). - Rename allow-bash-find-in-project → allow-bash-find and broaden it to any path under $HOME (dangerous find flags still rejected at the AST level). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 863683a3-afd4-4c78-936f-804cd1aaed15
Add five capability-bounded tools to the mid-pipeline safe-filter set and add a steering deny policy that points perl one-liners at them. isSafeFilter additions: - rg: allowed mid-pipeline; flags that exec/shell out are rejected (--pre, --preprocessor, --pre-glob, --hostname-bin, -z/--search-zip) along with arbitrary-file-read flags (-f/--file, --ignore-file). Positional args starting with / or ~ are rejected (exfil vector). - sd: allowed as a stdin filter (exactly 2 non-flag args). File-path positionals would mutate files in place and are rejected. - choose: allowed; -i/--input (arbitrary file read) is rejected. - xq: allowed; -i/--in-place, --rawfile, --slurpfile, -f/--from-file, -L/--library-path are rejected. At most one positional, no leading / or ~. Covers both Go (sibprogrammer) and Python (yq) variants. - htmlq: allowed; -f/--filename (read) and -o/--output (write) are rejected. allow-bash-grep-in-project: tightened at head-of-pipeline so the same dangerous rg flags can't slip through when rg is the leading command. deny-perl-one-liners: new deny policy that intercepts perl -e/-E/-ne/-pe /-ane/-nle/-ple/-pae (detected by lowercase short-flag combos containing 'e', or exact -E) anywhere in a pipeline/chain and returns a steering message listing the safe-by-design alternatives (rg --replace, sed -n, cut/choose, sd, htmlq, xq, jq). `perl script.pl`, `perl -d`, etc. fall through. .gitignore: add .DS_Store and tmp/. package.json: bump 1.3.0 → 1.5.0 (minor — new policy, broader safe-filter coverage, no API break). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 863683a3-afd4-4c78-936f-804cd1aaed15
Documents the action-based policy API change introduced in 1.0: - Old (Middleware/VerdictResult) vs new (action + truthy/void) handler shape with side-by-side examples - Migration tables for allow/deny return values - Test migration via adaptHandler - The deny-before-allow ordering change and why - Legacy compat shim (works in 1.x, removed in 2.0) - Renamed built-in policies (Allow ls in project → Allow ls, Allow bash find in project → Allow bash find) for users of `disable` lists Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 863683a3-afd4-4c78-936f-804cd1aaed15
The new test files added in this branch (allow-memory-crud, allow-ls, allow-toolgate-test) inadvertently used real local paths as fixtures. Swap to the generic `/home/user/project` convention already used elsewhere in the test suite — the assertions don't care about the specific path, only the shape. - allow-memory-crud.test.ts: -Users-bryce-Dev-toolgate → -home-user-project - allow-ls.test.ts: /Users/bryce/.gnupg → /home/user/.gnupg - allow-toolgate-test.test.ts: /Users/bryce/Dev/pixelwatch → /home/user/project Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 863683a3-afd4-4c78-936f-804cd1aaed15
When a wrangler filter (jq, fx, yq, xq, htmlq, mlr, jp, jpx) errors out mid-pipeline, the LHS work is wasted. Failure-log scan showed ~120 such cases — mostly `cat <long-path> | fx '<JS>'` where fx's JS errored and the cat got retyped. Policy denies these pipes and steers to a save-then- iterate pattern using each wrangler's native file-arg support. Excludes gron (pure transformer, no filter to typo) and standard text filters (grep, head, sort, etc., handled by the existing safe-filter set). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 29397a34-23c6-44af-8fee-0d6a658c6dff
When jq/fx/yq/gron is invoked with a trivial filter (".", "_", or
absent) on a file under 10KB, steer to the Read tool instead.
Transcript scan showed 75% of wrangler-pipe calls were `cat <file> |
fx ...` — and a large fraction of those are pretty-prints or shallow
lookups Read can do without subprocess fork or filter syntax.
Stat-based check (~1µs/op, negligible vs the 2.5ms shfmt parse
toolgate already runs per Bash call). Skips when the filter is real
(slimming/transforming is the legitimate use of a wrangler), when the
file is too big to context-inject, when it doesn't exist, when piped
(deny-wrangler-pipes handles that), or when the positional is a
directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude-Session: 29397a34-23c6-44af-8fee-0d6a658c6dff
Adds an `Allow magick in project` policy gated by a 13-flag allowlist that covers basic ImageMagick v7 operations and rejects the wider surface (delegates, scripting, modules, debug logging, pseudo-format inputs, scheme URLs). Allowed: `-version`, `-threshold`, `-negate`, `-fuzz`, `+opaque`, `-channel`, `-separate`, `-resize`, `-crop`, `-gravity`, `+repage`, `-quality`, `-connected-components`. Inputs must be plain paths (no `<scheme>:` prefix, no `@file`, no `~`) resolving under the project root. Output may be such a path or `info:` / `null:` (read-only / discard sinks). Anything else falls through to ask. Transcript scan showed 18 real `magick` invocations, all in KOSites asset work — resizes, crops, threshold/negate, connected-components analysis. Resize/crop/threshold writes into the project tree auto-allow with this policy; the analysis calls (using `-format` or `-define`) still prompt, matching the conservative bound chosen for this iteration. Pkg: 1.7.0 → 1.7.1 (patch — single additive policy, no API change). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 53b0653c-f3d2-4339-8eb3-e41faa957f3c
`cmd1 && cmd2` shapes hide intent from per-call policy evaluation, audit logging, and permission caching — Claude makes one Bash call, toolgate sees one decision. Forcing decomposition into separate Bash calls gives each step its own evaluation, with the shell's cwd persisting across calls. Transcript scan of 909 sessions found 1,754 raw `&&` chain calls (5.3% of all Bash) — true count is lower after the AST excludes quoted `&&` inside `bash -c "..."` and inside fx/jq filter strings. Dominant decomposable shapes: mkdir+work, work+verify, inspect+work, rm+rmdir, sleep+gh, python3+wc. Exempts chains whose leaves include `eval`, `source`, `.`, or `export` (env-setters whose effects can't survive call decomposition). Uses a permissive first-word extractor that handles CallExpr, DeclClause (export/declare/local/readonly), and Pipe leaves — `getArgs` would bail on `eval "$(...)"` because of the CmdSubst. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 29397a34-23c6-44af-8fee-0d6a658c6dff
Bare `which X` and pipelines like `which X | head -1` auto-allow. Chains like `which X && X --version` continue to prompt via deny-and-chains rather than risk a misleading deny-mixed-pure message; that pattern wants the caller to split into two Bash calls anyway. Bumps to 1.9.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 33f40564-5557-4aac-997f-cc2bfaccaba5
Auto-allows the standalone form (e.g. `node --version`, `ffmpeg -version`, `git --help`), including pipelines to safe filters like `head -1`. Requires exactly two tokens — `<cmd> --version`, `<cmd> -version`, or `<cmd> --help` — to keep extra positionals from being interpreted by the tool. Single-letter `-V` / `-h` are intentionally not allowed (ambiguous — e.g. `ls -h` means human-readable, not help). Bumps to 1.10.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 33f40564-5557-4aac-997f-cc2bfaccaba5
Auto-allows `lsof` (and pipelines through safe filters like `head`, `grep`, `wc`) for diagnostic uses — port checks, file-handle lookups, process inspection. No flags reach the filesystem destructively. Bumps to 1.11.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 33f40564-5557-4aac-997f-cc2bfaccaba5
Permits `rmdir <project>/tmp` (and empty subdirs under it). rmdir refuses to remove non-empty directories regardless of flags, so the worst case is removing scaffolding the model just created. Bumps to 1.12.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 33f40564-5557-4aac-997f-cc2bfaccaba5
Extends allow-memory-crud to permit `ls` of `~/.claude/projects/*/memory/` and any path inside it (with or without flags like `-la`). Mixed paths or chains are still rejected. Bumps to 1.13.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 33f40564-5557-4aac-997f-cc2bfaccaba5
Adds three in-progress design docs under docs/plans/ and ignores the auto-generated docs/mem-proposals/ output directory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 33f40564-5557-4aac-997f-cc2bfaccaba5
Backtest across 943 ~/.claude/projects transcripts (35,616 Bash calls, 2,379 fx invocations) found the file-first form `fx file.json '<expr>'` errored 65% of the time (87/133) vs 11% for the redirect form `fx '<expr>' < file.json` (6/54). Both deny-wrangler-pipes and redirect-python-json-to-fx were steering users at the broken form. - deny-wrangler-pipes.ts STEERING_MESSAGE: `Good:` example + canonical form list now use the redirect form; parenthetical names the two parser traps (leading `/` → regex literal, `.field` → strict-mode reserved word) that motivate avoiding the file-first shape. - redirect-python-json-to-fx.ts DENY_MESSAGE: was recommending pipe-form fx (denied by its sibling policy); now recommends save-then-redirect. Mem Decision filed: 019ed019-d1e6 (grounded_in 019ed019-d1e7; resolves the recurring fx-abs-path-regex and fx-field-shorthand Struggle entries). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 0002be7b-98aa-42e8-bbde-ddacd8b19c40
v1.13.1's steering rewrite directed users to `fx '<expr>' < file.json`, but allow-safe-read-commands only checked positional args. fx wasn't in the set and `<` redirect targets were ignored — so the recommended form silently fell through to a permission prompt. - Add `fx` to SAFE_READ_COMMANDS. - Add getStdinRedirectPaths() and include `<` targets in the in-project containment check, so `cmd < file` is gated the same as `cmd file`. Side benefit: closes a pre-existing read-escape — `cat < /etc/passwd` was ALLOW before (positionals empty, cwd in project) and now ASKs. Tests: 14 new cases (fx in/out of project, stdin-redirect in/out of project across cat/head/wc/jq). Full suite 1,692/1,692. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: 0002be7b-98aa-42e8-bbde-ddacd8b19c40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #7.
⚠ Breaking change (0.x → 1.x)
This is the major-version cut for toolgate. The policy authoring API changed: policies now declare
action: "deny" | "allow"and handlers returnstring | boolean | voidinstead ofVerdictResult. The engine partitions and runs all deny policies before any allow policies regardless of array order, so a safety-critical deny can't be accidentally overridden by an over-broad allow.Legacy policies (no
actionfield) still work in 1.x through a compat shim, but it will be removed in 2.0.→ See
docs/migration-1.x.mdfor the full migration guide (before/after examples, test migration viaadaptHandler, renamed built-in policies, order semantics).Summary
Three logical groups across 11 commits:
Action-based policy API (0.x → 1.0) — 7 commits
Policies now declare
action: "deny" | "allow"and returnstring | boolean | void. Theallow()/deny()/next()helpers andVerdictResultare now engine-internal — policy authors no longer touch them. All 63 built-in policies and the project config migrated.Auto-memory plumbing + small policies — 1 commit (
c23dd40)~/.claude/projects/<encoded>/memory/<file>so the auto-memory system in global CLAUDE.md worksallow-memory-crud: Read/Write/Edit/Update +rm <single-file>for memory pathsallow-date:dateand&&chains for reading/formatting time (rejects--set)allow-toolgate-test:toolgate test ...(dry-run policy check, never executes)allow-ls-in-project→allow-ls: any path with no dot-prefixed segmentsallow-bash-find-in-project→allow-bash-find: any path under$HOMESafe-filter expansion + perl steering — 1 commit (
fcfc56c, v1.5)rg,sd,choose,xq,htmlqnow auto-allowed as mid-pipeline filters with per-tool blocklists (rg --pre/--hostname-bin/-z/-f;choose -i;xq -i/--rawfile/--from-file/-L;htmlq -f/-o; path-like positional args forrgandxq)deny-perl-one-liners: catchesperl -e/-E/-ne/-peetc. anywhere in a pipeline/chain and returns a steering message naming the safe-by-design alternatives (rg --replace, sed -n, choose, sd, htmlq, xq, jq)allow-bash-grep-in-projecttightened so dangerousrgflags don't slip through at head-of-pipeline.gitignore: add.DS_Storeandtmp/Migration guide — 1 commit (
6f521ff)docs/migration-1.x.mdcovers the breaking change, mechanical migration patterns, test migration, order semantics, and renamed policies.Test plan
bun test(currently 1448 pass / 0 fail across 69 files)toolgate test Bash '{"command":"perl -ne ..."}'→ DENY with steering texttoolgate test Bash '{"command":"cat README.md | rg -oP ... --replace ... | sort -u"}'(single-quoted regex) → ALLOWtoolgate test Bash '{"command":"cat README.md | xq -i /tmp/out.xml"}'→ ASK (not auto-allowed)~/.claude/projects/.../memory/*.mdtoolgate listto confirm all built-in policies load post-refactortoolgate auditagainst an existing project's.claude/settings.local.json— should still produce a useful reportdocs/migration-1.x.mdagainst a downstream project (if any) — confirm the recipe actually compiles🤖 Generated with Claude Code