Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
37d3650
feat: add policy action types and handler adapter
brycehans Apr 22, 2026
90b4884
feat: partition policies by action — deny runs before allow
brycehans Apr 22, 2026
bd067b8
refactor: migrate deny policies to action-based handlers
brycehans Apr 22, 2026
a5b80d8
refactor: migrate allow policies to action-based handlers
brycehans Apr 22, 2026
cd47cbc
refactor: migrate project config to action-based policies
brycehans Apr 22, 2026
d8b08ab
docs: update CLAUDE.md for action-based policy authoring
brycehans Apr 22, 2026
98ec319
chore: bump version to 1.0.0 for action-based policy types
brycehans Apr 22, 2026
24b3445
Merge branch 'main' into feat/policy-action-types
brycehans Apr 23, 2026
c23dd40
feat: auto-memory write support and broaden ls/find scope
brycehans Jun 15, 2026
fcfc56c
feat: expand safe-filter set + deny perl one-liners with steering
brycehans Jun 15, 2026
6f521ff
docs: add 0.x → 1.x migration guide
brycehans Jun 15, 2026
539fbd5
chore: scrub personal paths from new test fixtures
brycehans Jun 15, 2026
57c1f2d
feat: deny pipes into data wranglers with steering
brycehans Jun 15, 2026
cc706b7
feat: redirect trivial wrangler-on-small-file to Read tool
brycehans Jun 15, 2026
db26b96
feat: allow magick with conservative flag allowlist for in-project paths
brycehans Jun 15, 2026
dd2ccf3
feat: deny && chains so each step is evaluated atomically
brycehans Jun 15, 2026
8acc762
feat: allow which for $PATH lookups
brycehans Jun 16, 2026
de3cb0a
feat: allow `<cmd> --version` / `--help` probes
brycehans Jun 16, 2026
22d5a81
feat: allow lsof for inspecting open files and sockets
brycehans Jun 16, 2026
c27d76e
feat: allow rmdir in project tmp/
brycehans Jun 16, 2026
5980f75
feat: allow ls of memory dir and files
brycehans Jun 16, 2026
3ff48c4
chore: add plan docs and ignore mem-proposals/
brycehans Jun 16, 2026
80de38f
fix: steer fx users to redirect form, not file-first arg
brycehans Jun 16, 2026
b2b83cb
fix: allow `fx '<expr>' < file.json` and gate `<` redirect targets
brycehans Jun 16, 2026
5bed907
docs: fix install command to use scoped package name
Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
node_modules/
dist/
toolgate.config.local.ts
.DS_Store
tmp/
docs/mem-proposals/
43 changes: 26 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ Use Bun exclusively — not Node.js, npm, yarn, or pnpm. Bun auto-loads `.env`.

### Core (`src/`)

- **`types.ts`** — `ToolCall`, `CallContext`, `VerdictResult`, `Middleware`, `Policy` type definitions
- **`verdicts.ts`** — Symbol-based verdict system: `ALLOW`, `DENY`, `NEXT` with helpers `allow()`, `deny(reason?)`, `next()`
- **`policy.ts`** — `definePolicy()` and `runPolicy()` — sequential policy chain, returns first non-NEXT verdict
- **`types.ts`** — `ToolCall`, `CallContext`, `VerdictResult`, `Policy`, `PolicyHandler` type definitions
- **`verdicts.ts`** — Symbol-based verdict system: `ALLOW`, `DENY`, `NEXT` (internal — policy authors don't use these directly)
- **`adapter.ts`** — `adaptHandler()` converts simplified policy handler returns (truthy/void) into internal `VerdictResult` objects
- **`policy.ts`** — `definePolicy()` and `runPolicy()` — partitions policies by action (deny first, allow second), returns first activated verdict
- **`config.ts`** — Walks from cwd up to `$HOME` collecting configs. At each level, loads `toolgate.config.local.ts` (personal, gitignored) before `toolgate.config.ts` (committed, team-shared); prefers `./` over `./.claude/`. Built-in policies are appended last.
- **`runner.ts`** — Bridges Claude Code hook stdin/stdout protocol to the policy engine
- **`cli.ts`** — Subcommands: `run` (hook handler), `init` (setup), `test` (dry-run), `list` (show loaded policies), `logs` (show log file paths)
Expand All @@ -36,9 +37,14 @@ Use Bun exclusively — not Node.js, npm, yarn, or pnpm. Bun auto-loads `.env`.

### Built-in Policies (`policies/`)

Each policy is a `Policy` object with `name`, `description`, and `handler`. The handler is a `Middleware` function that returns `next()` to pass through, `allow()` to permit, or `deny(reason)` to block.
Each policy is a `Policy` object with `name`, `description`, `action`, and `handler`.

Built-in policies are exported from `policies/index.ts` and automatically appended after any project-level policies. Project configs (`toolgate.config.ts`) can add extra policies via `definePolicy([...])`. Order matters — first non-NEXT verdict wins.
- **`action: "deny"`** — handler returns a string (deny with reason), `true` (deny without reason), or `void` (pass through)
- **`action: "allow"`** — handler returns `true` (allow) or `void` (pass through)

The engine **always runs deny policies before allow policies**, regardless of array order. This prevents an overly broad allow from overriding a safety-critical deny. Within each action group, policies run in their original order.

Built-in policies are exported from `policies/index.ts` and automatically appended after any project-level policies. Project configs (`toolgate.config.ts`) can add extra policies via `definePolicy([...])`. First activated verdict wins.

### Disabling Policies

Expand All @@ -60,24 +66,27 @@ When creating a new policy or renaming an existing one, you **must** update `pol

### Key Patterns

- **Whitelist approach**: Policies explicitly allow known-safe patterns; everything else falls through as `next()` (prompts user)
- **Whitelist approach**: Policies explicitly allow known-safe patterns; everything else falls through (prompts user)
- **Shell command safety**: Use `shfmt --tojson` (via `policies/parse-bash-ast.ts`) to parse Bash commands into typed ASTs. Use `safeBashCommand()` for simple commands, `safeBashCommandOrPipeline()` for commands that may pipe to safe filters, or `getAndChainSegments()` to decompose `&&` chains into leaf statements. These reject unsafe patterns (substitution, chaining, background, unsafe redirects) at the AST level.
- **Self-imports in tests**: Policy tests import from `"@brycehanscomb/toolgate"` (package self-reference) instead of relative `../../../src` paths
- **Policy handlers are async**: All handlers return `Promise<VerdictResult>`
- **Testing policy handlers directly**: Policy tests call `policyObj.handler(call)` to test the handler function
- **Policy handlers are async**: All handlers return `Promise<string | boolean | void>`
- **Testing policy handlers**: Tests wrap handlers with `adaptHandler()` to get `VerdictResult` objects for assertions: `const run = adaptHandler(policy.action!, policy.handler as any)`

## Writing a Policy

```ts
import { allow, next, type Policy } from "../src";
import type { Policy } from "../src";

const myPolicy: Policy = {
name: "My policy",
description: "Describes what this policy does",
action: "allow", // or "deny"
handler: async (call) => {
if (call.tool !== "Bash") return next();
if (call.tool !== "Bash") return;
// ... validation logic ...
return allow();
return true; // allow (for "allow" action) or deny (for "deny" action)
// return "reason string" — only for "deny" action, denies with a message
// return / return undefined — pass through to next policy
},
};
export default myPolicy;
Expand All @@ -95,15 +104,15 @@ For Bash policies that parse commands, use the AST helpers in `policies/parse-ba

**Project policies** (`toolgate.config.ts`) are repo-specific. Examples: Laravel artisan commands, project-specific WebFetch domains, custom build scripts.

### Ordering Convention
### Evaluation Order

The `builtinPolicies` array in `policies/index.ts` follows this order:
The engine enforces **deny-before-allow** evaluation order automatically via the `action` field. Array position in `policies/index.ts` only affects relative order within the same action group.

1. **Deny policies** — catch dangerous patterns first (`deny-git-add-and-commit`, `deny-writes-outside-project`, `deny-git-dash-c`)
2. **Redirect policies** — modify tool calls before evaluation (`redirect-plans-to-project`)
3. **Allow policies** — whitelist safe patterns (all `allow-*` policies)
- All `action: "deny"` policies run first — any truthy return short-circuits with a deny
- All `action: "allow"` policies run second — first truthy return allows
- If no policy activates, the user is prompted (ask)

New policies must be inserted at the correct position. First non-`next()` verdict wins, so a misplaced allow could override a deny.
This means a misplaced allow policy **cannot** override a deny policy, regardless of array order.

### When to Create a Built-in vs Leave as Static Rule

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ go install mvdan.cc/sh/v3/cmd/shfmt@latest
### Package

```bash
bun install -g toolgate
bun install -g @brycehanscomb/toolgate
```

## Setup
Expand Down
124 changes: 124 additions & 0 deletions docs/migration-1.x.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Migrating from toolgate 0.x to 1.x

The 1.0 release replaced the `Middleware`-style policy authoring API with a simpler declarative shape, and the engine now partitions policies so deny rules always run before allow rules regardless of array order. The 1.x series is **backwards-compatible** — legacy policies still work — but the compat shim will be removed in 2.0, so migrate now.

## The change at a glance

**Old API (0.x)** — handlers returned `VerdictResult` via the `allow()`/`deny()`/`next()` helpers:

```ts
import { allow, deny, next, type Policy } from "@brycehanscomb/toolgate";

const myPolicy: Policy = {
name: "Allow X",
description: "...",
handler: async (call) => {
if (call.tool !== "Bash") return next();
if (isOk(call)) return allow();
return next();
},
};
```

**New API (1.x)** — declare `action`, return truthy/void:

```ts
import type { Policy } from "@brycehanscomb/toolgate";

const myPolicy: Policy = {
name: "Allow X",
description: "...",
action: "allow",
handler: async (call) => {
if (call.tool !== "Bash") return;
if (isOk(call)) return true;
},
};
```

## What changed mechanically

| Old (0.x) | New (1.x) |
|---|---|
| handler returns `VerdictResult` via `allow()` / `deny()` / `next()` | handler returns `string \| boolean \| void` |
| no `action` field | `action: "deny" \| "allow"` declares intent |
| array order determines run order | engine partitions: **all denies run before any allows** |
| `import { allow, deny, next, type Policy }` | `import type { Policy }` |

## Migration patterns

### Allow policy

| Old | New |
|---|---|
| `return allow()` | `return true` |
| `return next()` | `return` (or just fall through) |

### Deny policy

| Old | New |
|---|---|
| `return deny("reason here")` | `return "reason here"` |
| `return deny()` | `return true` |
| `return next()` | `return` |

### Tests

Old assertions called the handler directly and inspected `.verdict`:

```ts
const result = await policy.handler(call);
expect(result.verdict).toBe(ALLOW);
```

Wrap the new-style handler through `adaptHandler` so your tests keep working:

```ts
import { adaptHandler, ALLOW, type ToolCall } from "@brycehanscomb/toolgate";

const run = adaptHandler(policy.action!, policy.handler as any);
const result = await run(call);
expect(result.verdict).toBe(ALLOW);
```

`adaptHandler` is the same shim the engine uses internally, so the test path matches production exactly.

## Order semantics changed (important)

**Before (0.x):** policies ran in array order, first non-`next()` won.

**After (1.x):** the engine partitions on `action`. Every `action: "deny"` policy runs first in its original relative order; every `action: "allow"` policy runs after; first activating verdict in that ordering wins.

**Implication:** you can no longer place an `allow` early in the array to pre-empt a later `deny` on the same tool. A deny on a tool will **always** fire before any allow gets a chance. This is intentional — it prevents broad allows from silently weakening safety-critical denies. If you were relying on array order to allow-before-deny, that's exactly the bug class this refactor closes.

## Legacy compat (1.x only)

Policies **without** an `action` field still work in 1.x — they're treated as legacy `Middleware`, called with the old `(call) => VerdictResult` signature, and run in the allow partition (after all `action: "deny"` policies). The compat path is in `src/policy.ts` if you want to read it.

This compat layer will be removed in 2.0. Migrate now to avoid surprise.

## Renamed built-in policies

If your `toolgate.config.ts` has a `disable: [...]` list referring to built-in policies by name, two were renamed and broadened in 1.5:

| Old name | New name | What changed |
|---|---|---|
| `Allow ls in project` | `Allow ls` | Now allows any path with no dot-prefixed segments (so `ls /tmp`, `ls ~/Downloads`), still blocks `ls .git`, `ls ~/.ssh`, etc. |
| `Allow bash find in project` | `Allow bash find` | Now allows any path under `$HOME` (dangerous find flags still rejected at AST level) |

If you relied on the project-root restriction, drop in a custom replacement policy.

## Other things to double-check

- `definePolicy()` in `toolgate.config.ts` — call signature unchanged, but the policies it accepts must use the new shape (or be legacy-shaped without `action`).
- `allow()`, `deny()`, `next()`, `ALLOW`, `DENY`, `NEXT` are still exported. They're used by the engine, by `adaptHandler`, and by tests. You only need to drop the imports from policy files where you no longer call them.
- `Policy.handler` is now typed as `PolicyHandler | Middleware` — TypeScript will infer correctly in both styles, but if you cast explicitly you may need to update.

## Quick checklist

1. Add `action: "deny" | "allow"` to every policy.
2. Rewrite handler returns: `allow()` → `true`, `deny("x")` → `"x"`, `deny()` → `true`, `next()` → bare `return`.
3. Drop unused imports of `allow` / `deny` / `next`.
4. Wrap test calls with `adaptHandler` instead of asserting on raw return values.
5. Audit any `disable` lists for renamed policy names.
6. Run your suite, then `toolgate list` to confirm everything loads.
155 changes: 155 additions & 0 deletions docs/plans/2026-04-06-amplitude-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Amplitude: Impact-Aware Permission Filtering for Toolgate

## Problem

Toolgate's policy engine makes binary per-call decisions (allow/deny/ask), but there's no way to globally shift how permissive the system is. Users want a single dial — "amplitude" — that controls how much latitude Claude gets, framed in human terms (observe / change local / change global) rather than tool categories.

## Core Insight

The MCP protocol already defines a vocabulary for tool impact: `readOnly`, `destructive`, `idempotent`, `openWorld`. Toolgate can compute these annotations per-invocation using domain knowledge (Bash AST parsing, file-path analysis), then use amplitude as a filter over them. This separates three concerns:

1. **Classification** — "what kind of thing is this?" (annotations)
2. **Policy** — "should this be permitted?" (allow/deny/next — unchanged)
3. **Amplitude** — "does the current trust level permit this kind of thing?"

## Data Model

### Annotations

Attached to every tool call after classification:

```ts
interface Annotations {
readOnly?: boolean; // doesn't modify state
destructive?: boolean; // hard to reverse
openWorld?: boolean; // visible outside this machine
idempotent?: boolean; // safe to repeat
}
```

Unset fields mean "unknown" — treated conservatively (assumed worst-case: not read-only, potentially destructive, open-world, non-idempotent).

### Amplitude Presets

Named constraint sets that define a trust threshold:

| Name | Constraint | Plain English |
|-----------|---------------------------|------------------------------------|
| `observe` | `readOnly === true` | Only let through reads |
| `local` | `openWorld !== true` | Anything that stays on my machine |
| `full` | *(no constraint)* | Everything not denied |

### Interaction with Policy Verdicts

- `deny()` — always final, amplitude cannot override
- `allow()` + annotations pass amplitude filter — allow
- `allow()` + annotations fail amplitude filter — downgrade to `next()` (ask user)
- `next()` — always ask, amplitude cannot upgrade

Amplitude only gates the upside. It never weakens the floor that deny policies establish.

## Architecture

### Pipeline

```
stdin → buildToolCall() → classify(call) → runPolicy(call) → amplitudeFilter(verdict, annotations) → stdout
```

### Classifier Layer

A chain of small functions, each characterizing tool calls it understands:

```ts
type Classifier = (call: ToolCall) => Promise<Partial<Annotations>>;
```

Returns only the fields it has an opinion on. Empty object means "I don't know about this call."

**Merging:** Classifiers run in order. Later classifiers override earlier ones. Fields merge with last-write-wins per field:

```ts
// classifier 1: { readOnly: true }
// classifier 2: { openWorld: true }
// merged: { readOnly: true, openWorld: true }

// classifier 3: { readOnly: false } — overrides classifier 1
// final: { readOnly: false, openWorld: true }
```

**Ordering convention:** General classifiers first (MCP self-declarations, broad tool-type defaults), specific classifiers last (Bash command parsing, file-path analysis). Specific overrides general.

### Built-in Classifiers

1. **MCP defaults** — passes through self-declared annotations from MCP tools as an untrusted baseline
2. **Tool-type defaults** — `Read`/`Glob`/`Grep` → `{ readOnly: true }`, `Edit`/`Write` → `{ readOnly: false, openWorld: false }`
3. **Bash command classifier** — parses the command via shfmt AST, tags `git push` as openWorld, `rm` as destructive, `cat` as readOnly, etc.
4. **File-path classifier** — if the target file is CI config, `.env`, or similar → `{ openWorld: true }` (editing a deploy pipeline has external consequences)

**Unknown tool calls:** If no classifier claims it, all fields stay unset. Unset = worst-case. Unknown tools at `observe` amplitude get downgraded to ask — safe by default.

### Amplitude Filter

The gate between policy verdict and final response:

```ts
function amplitudeFilter(
verdict: VerdictResult,
annotations: Annotations,
amplitude: AmplitudePreset
): VerdictResult {
if (verdict.verdict === DENY) return verdict;
if (verdict.verdict === NEXT) return verdict;

if (passesAmplitude(annotations, amplitude)) return verdict;

return next(); // downgrade to ask
}
```

```ts
function passesAmplitude(a: Annotations, preset: AmplitudeConstraints): boolean {
if (preset.readOnly === true && a.readOnly !== true) return false;
if (preset.openWorld === false && a.openWorld !== false) return false;
return true;
}
```

### Configuration

Top-level field in `toolgate.config.ts` or CLI flag:

```ts
// toolgate.config.ts
export const amplitude = "local";
```

```bash
# or CLI override
toolgate run --amplitude observe
```

CLI flag overrides config file. Default if unset: `full` (backward compatible).

## What Doesn't Change

- **Policy authoring** — `allow()`, `deny()`, `next()` unchanged. No new fields on `Policy`.
- **Deny policies** — always fire regardless of amplitude.
- **Policy ordering** — first non-`next()` wins.
- **`definePolicy()`** — unchanged.
- **Project configs** — work exactly as before.

## Design Principles

- **Human-shaped levels** — observe/local/full maps to "can anyone else see this?", not tool categories
- **Fail safe** — unknown annotations = worst-case assumption
- **Amplitude only downgrades** — can tighten permissions, never loosen beyond what policies allow
- **Separation of concerns** — classification, policy, and amplitude are independent layers
- **Backward compatible** — amplitude defaults to `full`, existing configs work unchanged

## Future Extensions

- **Custom amplitude presets** — user-defined constraint sets beyond the three built-ins
- **Project-level classifiers** — `toolgate.config.ts` can register additional classifiers
- **Amplitude in hook response** — return annotations alongside the verdict so Claude Code can display impact info
- **Dynamic amplitude** — switch amplitude mid-session (e.g., "go to observe mode while I'm AFK")
Loading