Skip to content

feat: action-based policy API + auto-memory + safe-filter expansion (v1.5)#8

Open
brycehans wants to merge 25 commits into
mainfrom
feat/policy-action-types
Open

feat: action-based policy API + auto-memory + safe-filter expansion (v1.5)#8
brycehans wants to merge 25 commits into
mainfrom
feat/policy-action-types

Conversation

@brycehans

@brycehans brycehans commented Apr 22, 2026

Copy link
Copy Markdown
Owner

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 return string | boolean | void instead of VerdictResult. 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 action field) still work in 1.x through a compat shim, but it will be removed in 2.0.

See docs/migration-1.x.md for the full migration guide (before/after examples, test migration via adaptHandler, 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 return string | boolean | void. The allow()/deny()/next() helpers and VerdictResult are 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)

  • Allow writes under ~/.claude/projects/<encoded>/memory/<file> so the auto-memory system in global CLAUDE.md works
  • allow-memory-crud: Read/Write/Edit/Update + rm <single-file> for memory paths
  • allow-date: date and && chains for reading/formatting time (rejects --set)
  • allow-toolgate-test: toolgate test ... (dry-run policy check, never executes)
  • Broaden allow-ls-in-projectallow-ls: any path with no dot-prefixed segments
  • Broaden allow-bash-find-in-projectallow-bash-find: any path under $HOME

Safe-filter expansion + perl steering — 1 commit (fcfc56c, v1.5)

  • rg, sd, choose, xq, htmlq now 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 for rg and xq)
  • deny-perl-one-liners: catches perl -e/-E/-ne/-pe etc. 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-project tightened so dangerous rg flags don't slip through at head-of-pipeline
  • .gitignore: add .DS_Store and tmp/
  • Bump 1.0.0 → 1.5.0

Migration guide — 1 commit (6f521ff)

docs/migration-1.x.md covers 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 text
  • toolgate test Bash '{"command":"cat README.md | rg -oP ... --replace ... | sort -u"}' (single-quoted regex) → ALLOW
  • toolgate test Bash '{"command":"cat README.md | xq -i /tmp/out.xml"}' → ASK (not auto-allowed)
  • Verify auto-memory write end-to-end: trigger a memory save in a real Claude Code session and confirm no prompt for ~/.claude/projects/.../memory/*.md
  • Spot-check toolgate list to confirm all built-in policies load post-refactor
  • toolgate audit against an existing project's .claude/settings.local.json — should still produce a useful report
  • Walk through docs/migration-1.x.md against a downstream project (if any) — confirm the recipe actually compiles

🤖 Generated with Claude Code

brycehans and others added 10 commits April 22, 2026 22:59
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
@brycehans brycehans changed the title feat: policy action types — deny before allow feat: action-based policy API + auto-memory + safe-filter expansion (v1.5) Jun 15, 2026
brycehans and others added 15 commits June 15, 2026 10:18
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider separating policies into restrictors vs liberators

1 participant