diff --git a/.gitignore b/.gitignore index 3c7a6e1..dd920f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules/ dist/ toolgate.config.local.ts +.DS_Store +tmp/ +docs/mem-proposals/ diff --git a/CLAUDE.md b/CLAUDE.md index 8b5ee50..816d08a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -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 @@ -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` -- **Testing policy handlers directly**: Policy tests call `policyObj.handler(call)` to test the handler function +- **Policy handlers are async**: All handlers return `Promise` +- **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; @@ -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 diff --git a/README.md b/README.md index f054854..d4206e9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/migration-1.x.md b/docs/migration-1.x.md new file mode 100644 index 0000000..860f7cf --- /dev/null +++ b/docs/migration-1.x.md @@ -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. diff --git a/docs/plans/2026-04-06-amplitude-design.md b/docs/plans/2026-04-06-amplitude-design.md new file mode 100644 index 0000000..1433c70 --- /dev/null +++ b/docs/plans/2026-04-06-amplitude-design.md @@ -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>; +``` + +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") diff --git a/docs/plans/2026-04-22-policy-action-types.md b/docs/plans/2026-04-22-policy-action-types.md new file mode 100644 index 0000000..a00d236 --- /dev/null +++ b/docs/plans/2026-04-22-policy-action-types.md @@ -0,0 +1,746 @@ +# Policy Action Types Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Split policies into `action: "deny"` and `action: "allow"` types with simplified handler return values (truthy = activate, void = pass), and enforce deny-before-allow evaluation order in the engine. + +**Architecture:** Add an `action` field to `Policy`. Handlers return `string | boolean | void` instead of `VerdictResult`. The engine partitions policies by action, runs all deny policies first (any truthy = deny), then allow policies (any truthy = allow), else falls through to ask. Existing `allow()`/`deny()`/`next()` helpers and `VerdictResult` remain for internal engine use only — policy authors no longer touch them. + +**Tech Stack:** TypeScript, Bun test runner, shfmt AST parsing (unchanged) + +**Related:** [GitHub Issue #7](https://github.com/brycehans/toolgate/issues/7), [Issue #6 (session-scoped policies)](https://github.com/brycehans/toolgate/issues/6) + +--- + +## Summary of Changes + +The new `Policy` type: +```ts +interface Policy { + name: string; + description: string; + action: "deny" | "allow"; + handler: (call: ToolCall) => Promise; +} +``` + +- **Allow policy** handler returns `true` → allow, `void` → pass +- **Deny policy** handler returns `true` → deny (no reason), `"reason"` → deny with reason, `void` → pass +- Engine runs all deny-action policies first, then allow-action policies +- `Middleware`, `VerdictResult`, `allow()`, `deny()`, `next()` become internal — no longer needed by policy authors +- `definePolicy()` still works for project configs but accepts the new shape +- Backward compat: the engine can detect old-style handlers (returning VerdictResult objects) and warn/adapt during migration, but this is optional — a clean cutover is fine since all policies are in-tree + +--- + +### Task 1: Update `Policy` type and add handler adapter + +**Files:** +- Modify: `src/types.ts` +- Create: `src/adapter.ts` +- Test: `src/tests/adapter.test.ts` + +**Step 1: Write failing tests for the adapter** + +Create `src/tests/adapter.test.ts`: + +```ts +import { describe, expect, it } from "bun:test"; +import { adaptHandler } from "../adapter"; +import { ALLOW, DENY, NEXT } from "../verdicts"; + +describe("adaptHandler", () => { + describe("allow action", () => { + it("converts true to ALLOW", async () => { + const handler = adaptHandler("allow", async () => true); + const result = await handler({} as any); + expect(result.verdict).toBe(ALLOW); + }); + + it("converts void/undefined to NEXT", async () => { + const handler = adaptHandler("allow", async () => {}); + const result = await handler({} as any); + expect(result.verdict).toBe(NEXT); + }); + + it("converts false to NEXT", async () => { + const handler = adaptHandler("allow", async () => false); + const result = await handler({} as any); + expect(result.verdict).toBe(NEXT); + }); + }); + + describe("deny action", () => { + it("converts true to DENY without reason", async () => { + const handler = adaptHandler("deny", async () => true); + const result = await handler({} as any); + expect(result.verdict).toBe(DENY); + expect("reason" in result).toBe(false); + }); + + it("converts string to DENY with reason", async () => { + const handler = adaptHandler("deny", async () => "not allowed here"); + const result = await handler({} as any); + expect(result.verdict).toBe(DENY); + expect((result as any).reason).toBe("not allowed here"); + }); + + it("converts void/undefined to NEXT", async () => { + const handler = adaptHandler("deny", async () => {}); + const result = await handler({} as any); + expect(result.verdict).toBe(NEXT); + }); + + it("converts false to NEXT", async () => { + const handler = adaptHandler("deny", async () => false); + const result = await handler({} as any); + expect(result.verdict).toBe(NEXT); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/tests/adapter.test.ts` +Expected: FAIL — module not found + +**Step 3: Update `src/types.ts`** + +Replace the `Policy` interface and `Middleware` type: + +```ts +import type { ALLOW, DENY, NEXT } from "./verdicts"; + +export interface ToolCall { + tool: string; + args: Record; + context: CallContext; +} + +export interface CallContext { + cwd: string; + env: Record; + projectRoot: string; + additionalDirs: string[]; +} + +export type VerdictResult = + | { verdict: typeof ALLOW } + | { verdict: typeof DENY; reason?: string } + | { verdict: typeof NEXT }; + +/** @internal Used by the engine to run adapted handlers */ +export type Middleware = (call: ToolCall) => Promise; + +/** New simplified handler signature for policy authors */ +export type PolicyHandler = (call: ToolCall) => Promise; + +export interface Policy { + name: string; + description: string; + action: "deny" | "allow"; + handler: PolicyHandler; +} +``` + +**Step 4: Create `src/adapter.ts`** + +```ts +import type { ToolCall, VerdictResult, PolicyHandler } from "./types"; +import type { Middleware } from "./types"; +import { allow, deny, next } from "./verdicts"; + +export function adaptHandler( + action: "deny" | "allow", + handler: PolicyHandler, +): Middleware { + return async (call: ToolCall): Promise => { + const result = await handler(call); + + // Falsy or void → pass through + if (result === undefined || result === null || result === false) { + return next(); + } + + if (action === "allow") { + return allow(); + } + + // action === "deny" + if (typeof result === "string") { + return deny(result); + } + return deny(); + }; +} +``` + +**Step 5: Update `src/index.ts` exports** + +Add the new types to the public API: + +```ts +export { ALLOW, DENY, NEXT, allow, deny, next, isVerdictResult } from './verdicts' +export type { ToolCall, CallContext, VerdictResult, Middleware, Policy, PolicyHandler } from './types' +export { definePolicy, runPolicy, runPolicyWithTrace } from './policy' +export type { TracedResult } from './policy' +export { isWithinProject, loadAdditionalDirs } from './project-dirs' +export { adaptHandler } from './adapter' +``` + +**Step 6: Run tests to verify they pass** + +Run: `bun test src/tests/adapter.test.ts` +Expected: PASS + +**Step 7: Commit** + +```bash +git add src/types.ts src/adapter.ts src/tests/adapter.test.ts src/index.ts +git commit -m "feat: add policy action types and handler adapter" +``` + +--- + +### Task 2: Update `runPolicy` to partition by action + +**Files:** +- Modify: `src/policy.ts` +- Create: `src/tests/policy-action-order.test.ts` + +**Step 1: Write failing tests for action-based ordering** + +Create `src/tests/policy-action-order.test.ts`: + +```ts +import { describe, expect, it } from "bun:test"; +import { runPolicy, runPolicyWithTrace } from "../policy"; +import { ALLOW, DENY, NEXT } from "../verdicts"; +import type { Policy, ToolCall } from "../types"; + +const call: ToolCall = { + tool: "Bash", + args: { command: "echo hi" }, + context: { cwd: "/tmp", env: {}, projectRoot: "/tmp", additionalDirs: [] }, +}; + +describe("runPolicy action ordering", () => { + it("runs deny policies before allow policies regardless of array order", async () => { + const log: string[] = []; + + const allowFirst: Policy = { + name: "allow-first", + description: "", + action: "allow", + handler: async () => { log.push("allow"); return true; }, + }; + const denySecond: Policy = { + name: "deny-second", + description: "", + action: "deny", + handler: async () => { log.push("deny"); }, // pass through + }; + + // allow is listed first, but deny should run first + const result = await runPolicy([allowFirst, denySecond], call); + expect(log).toEqual(["deny", "allow"]); + expect(result.verdict).toBe(ALLOW); + }); + + it("deny policy short-circuits before allow policies run", async () => { + const log: string[] = []; + + const allowPolicy: Policy = { + name: "allow-it", + description: "", + action: "allow", + handler: async () => { log.push("allow"); return true; }, + }; + const denyPolicy: Policy = { + name: "deny-it", + description: "", + action: "deny", + handler: async () => { log.push("deny"); return "blocked"; }, + }; + + const result = await runPolicy([allowPolicy, denyPolicy], call); + expect(log).toEqual(["deny"]); + expect(result.verdict).toBe(DENY); + }); + + it("preserves relative order within same action type", async () => { + const log: string[] = []; + + const deny1: Policy = { + name: "deny-1", + description: "", + action: "deny", + handler: async () => { log.push("deny-1"); }, + }; + const deny2: Policy = { + name: "deny-2", + description: "", + action: "deny", + handler: async () => { log.push("deny-2"); }, + }; + const allow1: Policy = { + name: "allow-1", + description: "", + action: "allow", + handler: async () => { log.push("allow-1"); return true; }, + }; + + await runPolicy([allow1, deny2, deny1], call); + // deny policies run first in original relative order, then allow + expect(log).toEqual(["deny-2", "deny-1", "allow-1"]); + }); + + it("returns NEXT when no policy activates", async () => { + const passThrough: Policy = { + name: "noop", + description: "", + action: "allow", + handler: async () => {}, + }; + + const result = await runPolicy([passThrough], call); + expect(result.verdict).toBe(NEXT); + }); + + it("trace returns correct policy name on deny", async () => { + const denyPolicy: Policy = { + name: "the-blocker", + description: "blocks stuff", + action: "deny", + handler: async () => "nope", + }; + + const { result, name } = await runPolicyWithTrace([denyPolicy], call); + expect(result.verdict).toBe(DENY); + expect(name).toBe("the-blocker"); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/tests/policy-action-order.test.ts` +Expected: FAIL — policies don't have `action` field yet in test, and `runPolicy` doesn't partition + +**Step 3: Update `src/policy.ts`** + +Replace the implementation to partition by action and adapt handlers: + +```ts +import type { Policy, ToolCall, VerdictResult } from './types' +import { isVerdictResult, next, NEXT } from './verdicts' +import { adaptHandler } from './adapter' + +export function definePolicy(policies: Policy[]): Policy[] { + return policies +} + +export interface TracedResult { + result: VerdictResult + /** Index of the policy in the original input array, or -1 if all passed */ + index: number + /** Name of the policy, if available */ + name: string | null + /** Description of the policy, if available */ + description: string | null +} + +export async function runPolicy(policies: Policy[], call: ToolCall): Promise { + const { result } = await runPolicyWithTrace(policies, call) + return result +} + +export async function runPolicyWithTrace(policies: Policy[], call: ToolCall): Promise { + // Partition into deny-first, allow-second, preserving relative order within each group + const denyPolicies: { policy: Policy; originalIndex: number }[] = [] + const allowPolicies: { policy: Policy; originalIndex: number }[] = [] + + for (let i = 0; i < policies.length; i++) { + const p = policies[i] + if (p.action === 'deny') { + denyPolicies.push({ policy: p, originalIndex: i }) + } else { + allowPolicies.push({ policy: p, originalIndex: i }) + } + } + + const ordered = [...denyPolicies, ...allowPolicies] + + for (const { policy, originalIndex } of ordered) { + const adapted = adaptHandler(policy.action, policy.handler) + const result = await adapted(call) + + if (!isVerdictResult(result)) { + throw new Error( + `toolgate: policy[${originalIndex}] "${policy.name}" returned invalid verdict: ${JSON.stringify(result)}\n` + + ` Every policy handler must return allow(), deny(), or next().` + ) + } + + if (result.verdict !== NEXT) { + return { result, index: originalIndex, name: policy.name, description: policy.description } + } + } + + return { result: next(), index: -1, name: null, description: null } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/tests/policy-action-order.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/policy.ts src/tests/policy-action-order.test.ts +git commit -m "feat: partition policies by action — deny runs before allow" +``` + +--- + +### Task 3: Migrate all deny policies + +Every deny policy needs: add `action: "deny"`, change handler to return `string | void` instead of `deny(reason)` / `next()`. + +**Files to modify (7 files):** +- `policies/deny-git-add-and-commit.ts` +- `policies/deny-writes-outside-project.ts` +- `policies/deny-git-dash-c.ts` +- `policies/deny-cd-chained.ts` +- `policies/deny-git-chained.ts` +- `policies/deny-gh-heredoc.ts` +- `policies/deny-ssh-compound.ts` +- `policies/deny-mixed-pure-chains.ts` +- `policies/redirect-plans-to-project.ts` (this is a deny-action policy) +- `policies/redirect-python-json-to-fx.ts` (this is a deny-action policy) + +**Migration pattern for each deny policy:** + +Before: +```ts +import { deny, next, type Policy } from "../src"; + +const myPolicy: Policy = { + name: "Deny something", + description: "...", + handler: async (call) => { + if (call.tool !== "Bash") return next(); + if (isDangerous(call)) return deny("reason here"); + return next(); + }, +}; +``` + +After: +```ts +import type { Policy } from "../src"; + +const myPolicy: Policy = { + name: "Deny something", + description: "...", + action: "deny", + handler: async (call) => { + if (call.tool !== "Bash") return; + if (isDangerous(call)) return "reason here"; + }, +}; +``` + +Key changes: +- Remove `deny` and `next` imports (keep only `type Policy` and any AST helpers) +- Add `action: "deny"` +- Replace `return deny("reason")` → `return "reason"` +- Replace `return deny()` → `return true` +- Replace `return next()` → `return` (or just let function fall through) + +**Step 1: Migrate all deny policies** + +Apply the pattern above to each of the 10 files listed. + +**Step 2: Run deny policy tests** + +Run: `bun test policies/tests/deny-*.test.ts policies/tests/redirect-*.test.ts` +Expected: PASS (tests check `result.verdict` which comes from the adapted handler — still works since the adapter converts return values to VerdictResult objects) + +Wait — the tests call `policy.handler(call)` directly and check `.verdict`. With the new handler signature, `handler()` returns `string | boolean | void`, not a VerdictResult. The tests will break. + +**Decision:** Update the tests in the same task. The test migration pattern: + +Before: +```ts +const result = await policy.handler(bash(cmd)); +expect(result.verdict).toBe(DENY); +``` + +After: +```ts +const result = await policy.handler(bash(cmd)); +expect(result).toBe("reason string"); // or: expect(result).toBe(true) for no-reason deny +``` + +And for pass-through: +```ts +const result = await policy.handler(bash(cmd)); +expect(result).toBeUndefined(); // was NEXT +``` + +Actually, to keep tests consistent and still test the full pipeline, add a small helper: + +```ts +import { adaptHandler } from "@brycehanscomb/toolgate"; + +// Wrap handler to get VerdictResult for assertions +function adapted(policy: Policy) { + return adaptHandler(policy.action, policy.handler); +} + +// Usage: +const result = await adapted(policy)(bash(cmd)); +expect(result.verdict).toBe(DENY); +``` + +This avoids rewriting every assertion and tests the full adapt→verdict flow. + +**Step 3: Migrate all deny policy test files (10 files)** + +Test files to update: +- `policies/tests/deny-git-add-and-commit.test.ts` +- `policies/tests/deny-writes-outside-project.test.ts` +- `policies/tests/deny-cd-chained.test.ts` +- `policies/tests/deny-git-chained.test.ts` +- `policies/tests/deny-gh-heredoc.test.ts` +- `policies/tests/deny-mixed-pure-chains.test.ts` +- `policies/tests/redirect-plans-to-project.test.ts` +- `policies/tests/redirect-python-json-to-fx.test.ts` + +Add `adaptHandler` import and wrap `policy.handler` calls through it. + +**Step 4: Run all deny/redirect tests** + +Run: `bun test policies/tests/deny-*.test.ts policies/tests/redirect-*.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add policies/deny-*.ts policies/redirect-*.ts policies/tests/deny-*.test.ts policies/tests/redirect-*.test.ts +git commit -m "refactor: migrate deny policies to action-based handlers" +``` + +--- + +### Task 4: Migrate all allow policies + +Same pattern but for allow policies — 50+ files. + +**Migration pattern for each allow policy:** + +Before: +```ts +import { allow, next, type Policy } from "../src"; + +const myPolicy: Policy = { + name: "Allow something", + description: "...", + handler: async (call) => { + if (call.tool !== "Bash") return next(); + if (isSafe(call)) return allow(); + return next(); + }, +}; +``` + +After: +```ts +import type { Policy } from "../src"; + +const myPolicy: Policy = { + name: "Allow something", + description: "...", + action: "allow", + handler: async (call) => { + if (call.tool !== "Bash") return; + if (isSafe(call)) return true; + }, +}; +``` + +Key changes: +- Remove `allow` and `next` imports +- Add `action: "allow"` +- Replace `return allow()` → `return true` +- Replace `return next()` → `return` (or fall through) + +**Step 1: Migrate all allow policy files** + +All `policies/allow-*.ts` files (there are ~50). + +**Step 2: Migrate all allow policy test files** + +Same pattern as Task 3 — add `adaptHandler` wrapper or test raw return values. + +Test files: all `policies/tests/allow-*.test.ts` + +**Step 3: Run all policy tests** + +Run: `bun test policies/tests/` +Expected: PASS + +**Step 4: Commit** + +```bash +git add policies/allow-*.ts policies/tests/allow-*.test.ts +git commit -m "refactor: migrate allow policies to action-based handlers" +``` + +--- + +### Task 5: Migrate project config and update `definePolicy` + +**Files:** +- Modify: `toolgate.config.ts` (root project config) +- Modify: `src/testing.ts` (testPolicy helper) + +**Step 1: Update `toolgate.config.ts`** + +Migrate the two inline policies to the new shape: + +```ts +import { definePolicy } from "./src/index"; + +const CLAUDE_DIR = `${homedir()}/.claude`; +const FILE_TOOLS = new Set(["Read", "Write", "Edit"]); +const PATH_TOOLS = new Set(["Glob", "Grep"]); + +function getPath(tool: string, args: Record): string | null { + if (FILE_TOOLS.has(tool)) return typeof args.file_path === "string" ? args.file_path : null; + if (PATH_TOOLS.has(tool)) return typeof args.path === "string" ? args.path : null; + return null; +} + +export default definePolicy([ + { + name: "Allow CRUD in ~/.claude", + description: "Permits Read/Write/Edit/Glob/Grep on paths within ~/.claude", + action: "allow", + handler: async (call) => { + if (!FILE_TOOLS.has(call.tool) && !PATH_TOOLS.has(call.tool)) return; + const path = getPath(call.tool, call.args); + if (!path) return; + if (path === CLAUDE_DIR || path.startsWith(CLAUDE_DIR + "/")) return true; + }, + }, + { + name: "Allow claude-code-guide agent", + description: "Permits the claude-code-guide read-only research agent", + action: "allow", + handler: async (call) => { + if (call.tool !== "Agent") return; + if (call.args.subagent_type !== "claude-code-guide") return; + return true; + }, + }, +]); +``` + +**Step 2: Update `src/testing.ts`** + +The `testPolicy` function calls `runPolicy` which now handles adaptation internally, so it should still work. Verify by running existing tests that use `testPolicy`. + +**Step 3: Run full test suite** + +Run: `bun test` +Expected: PASS + +**Step 4: Commit** + +```bash +git add toolgate.config.ts src/testing.ts +git commit -m "refactor: migrate project config to action-based policies" +``` + +--- + +### Task 6: Clean up exports and update CLAUDE.md + +**Files:** +- Modify: `src/index.ts` — consider whether `allow()`, `deny()`, `next()` should still be public exports +- Modify: `CLAUDE.md` — update policy authoring examples + +**Step 1: Update exports** + +Keep `allow`, `deny`, `next`, `ALLOW`, `DENY`, `NEXT` exported — they're still used by `testing.ts` assertions and the engine. But policy authors no longer need them. Update `CLAUDE.md` to reflect the new authoring pattern. + +**Step 2: Update `CLAUDE.md` policy examples** + +Replace the "Writing a Policy" section with the new pattern: + +```ts +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; + if (isSafe(call)) return true; + // implicit return = pass through to next policy + }, +}; +export default myPolicy; +``` + +Update the architecture section to explain: +- `action: "deny"` policies run first — return a string (deny with reason) or `true` (deny) to block +- `action: "allow"` policies run second — return `true` to permit +- Returning `undefined`/`void` passes through to the next policy +- The engine guarantees deny-before-allow ordering regardless of array position + +**Step 3: Commit** + +```bash +git add src/index.ts CLAUDE.md +git commit -m "docs: update CLAUDE.md for action-based policy authoring" +``` + +--- + +### Task 7: Run full test suite and verify + +**Step 1: Run all tests** + +Run: `bun test` +Expected: ALL PASS + +**Step 2: Manual smoke test** + +Run: `echo '{"tool_name":"Bash","tool_input":{"command":"git status"},"cwd":"/tmp"}' | bun run src/cli.ts run` +Expected: JSON output with `permissionDecision: "allow"` (from allow-git-status policy) + +Run: `echo '{"tool_name":"Bash","tool_input":{"command":"git add . && git commit -m test"},"cwd":"/tmp"}' | bun run src/cli.ts run` +Expected: JSON output with `permissionDecision: "deny"` (from deny-git-add-and-commit) + +**Step 3: Run `toolgate list` to verify policies load** + +Run: `bun run src/cli.ts list` +Expected: All policies listed with names and descriptions + +**Step 4: Bump version** + +Bump minor version in `package.json` (this is a new feature / breaking change to policy authoring API). + +**Step 5: Final commit** + +```bash +git add package.json +git commit -m "chore: bump version for action-based policy types" +``` diff --git a/docs/plans/2026-04-22-session-scoped-policies.md b/docs/plans/2026-04-22-session-scoped-policies.md new file mode 100644 index 0000000..570e7f6 --- /dev/null +++ b/docs/plans/2026-04-22-session-scoped-policies.md @@ -0,0 +1,155 @@ +# Session-Scoped Policies + +**Issue:** [#6](https://github.com/brycehans/toolgate/issues/6) +**Date:** 2026-04-22 + +## Problem + +Toolgate policies persist indefinitely in config files. Autonomous workflows (e.g. executing a test plan across staging/production) need temporary, targeted permissions. Current options are all bad: pre-approve broadly (too permissive), get prompted for each call (disruptive), or manually edit configs before/after (error-prone). + +## Design + +### Mechanism + +Two environment variables let a Claude Code session opt into an additional policy file. The file is prepended to the policy chain with highest priority. + +```bash +TOOLGATE_SESSION_FILE=/abs/path/to/session.ts TOOLGATE_SESSION_HASH=abc123... claude --continue +``` + +`seal` outputs a ready-to-use line: +``` +$ toolgate session seal ./my-session.ts +TOOLGATE_SESSION_FILE=/absolute/path/to/my-session.ts TOOLGATE_SESSION_HASH=e3b0c44298fc... +``` + +### Security Model + +**Threat:** An "evil Claude" could attempt to grant itself permissions via session policies. + +**Defense:** The env vars are set by the user in the parent process before launching Claude. Claude cannot modify parent process environment variables — `export` in a Bash tool call dies with the subprocess. A SHA-256 content hash in `TOOLGATE_SESSION_HASH` prevents Claude from mutating the policy file mid-session. On every tool call, toolgate re-verifies the file hash against `TOOLGATE_SESSION_HASH`. Mismatch → the tool call is **denied** with an explicit error message ("session policy file has been modified"). This fails loudly so the user knows their session permissions are no longer active. + +**Note on module caching:** The session policy file is loaded via `import()` once and cached by the runtime. The per-call hash check is a tamper-detection gate — it verifies the file hasn't changed, but does not re-execute the module. If the hash mismatches, the tool call is denied; the stale cached module is never used on mutated content. + +**Threat model explored and rejected:** +- File sentinels — Claude can write any file +- Crypto signing with on-disk keys — Claude can read the key +- TTY prompts — Claude can fake via `tmux send-keys`, `screen`, `script -c` +- Deny policies on approval commands — Claude can obfuscate through subprocess, scripts, base64 +- Env vars are the one trust boundary Claude cannot cross + +### Hardening: `.env` injection + +Bun auto-loads `.env` files from the working directory. Without mitigation, Claude could write `TOOLGATE_SESSION_FILE` and `TOOLGATE_SESSION_HASH` to a `.env` file and grant itself arbitrary permissions on the next tool call. + +**Mitigation:** At runtime, before loading session policies, toolgate scans `.env*` files from cwd up to `$HOME` (matching the same directory walk as config loading). This includes all variants Bun auto-loads: `.env`, `.env.local`, `.env.development`, `.env.production`, `.env.development.local`, `.env.production.local`. If `TOOLGATE_SESSION_FILE` or `TOOLGATE_SESSION_HASH` appears in any of these files, session policy loading is refused and the tool call is denied with an error explaining the `.env` injection attempt. + +This is a runtime check, not a shebang flag — it works regardless of how toolgate is invoked (`toolgate run`, `bun src/cli.ts run`, etc.). Tests must verify that `.env`-sourced session vars are not honored. + +**Defense-in-depth:** `cli.ts` should also use `#!/usr/bin/env -S bun --env-file=/dev/null` to disable `.env` auto-loading entirely. The runtime scan catches the session-specific attack; the shebang prevents the general class of `.env` injection for any future env-var-gated features. + +### Security: session policy files are executable code + +Session policy files are loaded via `import()`, which means they execute arbitrary TypeScript at load time — not just the exported policy array. A file with malicious top-level statements (network requests, file writes, etc.) will run on every tool call. + +The `seal` command must warn the user: **"This file will be executed as code on every tool call. Review the entire file, not just the exported policies."** Users must audit for side effects, not just policy declarations. + +### Flow + +1. A policy file is written (by a skill, by hand — origin doesn't matter) +2. User reviews the file +3. User runs `toolgate session seal ./policy.ts`, copies the output, and launches: `TOOLGATE_SESSION_FILE=... TOOLGATE_SESSION_HASH=... claude --continue` +4. Toolgate prepends the session policies before all other policies +5. On every tool call, toolgate re-verifies the file hash. Mismatch → tool call denied. + +**Tip:** To minimize the window between sealing and launching, combine into one line: +```bash +eval "$(toolgate session seal ./policy.ts)" claude --continue +``` + +### `toolgate session seal` CLI + +Single new subcommand: + +``` +toolgate session seal +``` + +- Validates the file exports a default array of policies (same as `loadConfigFile`) +- Computes SHA-256 of the file contents +- Prints a warning to stderr: `"⚠ This file will be executed as code on every tool call. Review the entire file, not just the exported policies."` +- Outputs a single line to stdout: `TOOLGATE_SESSION_FILE= TOOLGATE_SESSION_HASH=` +- User prepends this line to their `claude` command +- Rejects files that contain `import` or `require` statements targeting local paths (transitive imports escape the hash — only the entry file is hashed). Imports from `@brycehanscomb/toolgate` are allowed since they resolve to `node_modules` +- Warns if the file is inside a git worktree (subject to `git checkout`, editor auto-save, etc.) +- Exits non-zero if the file is invalid + +### Changes to `loadConfigs` + +At the top of `loadConfigs`, before the config walk: + +1. Read `process.env.TOOLGATE_SESSION_FILE` and `process.env.TOOLGATE_SESSION_HASH` +2. If either is missing, skip session policy loading +3. Read the file, compute SHA-256, compare to `TOOLGATE_SESSION_HASH` +4. If match → load via `loadConfigFile`, prepend to policy array +5. If mismatch or file missing → throw a `SessionPolicyError` (a distinct error class) with reason: `"toolgate: session policy file has been modified (hash mismatch)"` or `"toolgate: session policy file not found"`. The `runner.ts` catch block converts thrown errors into deny verdicts — this throw is **load-bearing for security**. The distinct class lets callers (e.g. `test`, `list`) distinguish "config failed to load" from "session integrity violated." + +Session policies go first → highest priority. Disable lists from project configs do not apply to session policies. + +### Example Session Policy + +```ts +import { allow, next, type Policy } from "@brycehanscomb/toolgate"; +import { safeBashCommand } from "@brycehanscomb/toolgate/policies/parse-bash-ast"; + +const policies: Policy[] = [ + { + name: "Session: allow WebFetch to staging", + description: "Allow GET/POST to kosites-staging.io", + handler: async (call) => { + if (call.tool !== "WebFetch") return next(); + try { + const hostname = new URL(call.args.url || "").hostname; + if (hostname === "kosites-staging.io" || hostname.endsWith(".kosites-staging.io")) + return allow(); + } catch {} + return next(); + }, + }, + { + name: "Session: allow gh issue edit #329", + description: "Allow editing issue 329 on ko-sites", + handler: async (call) => { + // Always use safeBashCommand() for Bash policies — never regex on raw command strings. + // Regex prefix checks allow command substitution in trailing args. + const args = safeBashCommand(call); + if (!args) return next(); + const [cmd, sub, num] = args; + if (cmd === "gh" && sub === "issue" && ["edit", "comment"].includes(args[2]) && args[3] === "329") + return allow(); + return next(); + }, + }, +]; + +export default policies; +``` + +## Not In Scope + +- No special directory convention — the file lives wherever you put it (though `/tmp/` is recommended over project directories to avoid git/editor interference) +- No session IDs — the env var points to a file, that's the identity +- No TTL / auto-cleanup — files are inert without the env var +- No proposal workflow — toolgate doesn't care who wrote the file +- No daemon / background process — toolgate stays stateless +- No changes to the `Policy` type — session policies are regular policies +- No multi-file support — one file per session +- No disable-list interaction — session policies can't be disabled by project configs, and can't disable other policies (can only add to the chain, not subtract) + +## Implementation Scope + +- ~15-line diff to `config.ts` (env var check, hash verify, prepend) +- New `seal` subcommand in `cli.ts` +- `SessionPolicyError` class in `src/` +- `cli.ts` shebang changed to `#!/usr/bin/env -S bun --env-file=/dev/null` +- Tests for: hash match (allow), hash mismatch (deny), file missing (deny), invalid file, prepend ordering, disable-list isolation, `.env`-sourced vars rejected (all `.env*` variants), `seal` prints code-execution warning, `seal` rejects files with local imports, `seal` warns on git worktree paths diff --git a/package.json b/package.json index 1e87c15..03c7b8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@brycehanscomb/toolgate", - "version": "0.4.0", + "version": "1.13.3", "devDependencies": { "bun-types": "latest" }, diff --git a/policies/allow-agent.ts b/policies/allow-agent.ts index 15cc8dc..7584a03 100644 --- a/policies/allow-agent.ts +++ b/policies/allow-agent.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow all Agent (subagent) tool calls unconditionally. @@ -6,12 +6,13 @@ import { allow, next, type Policy } from "../src"; const allowAgent: Policy = { name: "Allow agent", description: "Permits all Agent subagent invocations", + action: "allow", handler: async (call) => { if (call.tool !== "Agent") { - return next(); + return; } - return allow(); + return true; }, }; export default allowAgent; diff --git a/policies/allow-ask-user.ts b/policies/allow-ask-user.ts index 4710ff4..de110d7 100644 --- a/policies/allow-ask-user.ts +++ b/policies/allow-ask-user.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow all AskUserQuestion tool calls unconditionally. @@ -8,9 +8,10 @@ import { allow, next, type Policy } from "../src"; const allowAskUser: Policy = { name: "Allow AskUserQuestion", description: "Permits all AskUserQuestion tool calls", + action: "allow", handler: async (call) => { - if (call.tool !== "AskUserQuestion") return next(); - return allow(); + if (call.tool !== "AskUserQuestion") return; + return true; }, }; export default allowAskUser; diff --git a/policies/allow-aws-cli.ts b/policies/allow-aws-cli.ts index 389cb2e..a8fb52d 100644 --- a/policies/allow-aws-cli.ts +++ b/policies/allow-aws-cli.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; export interface AwsCliPolicyConfig { @@ -129,26 +129,27 @@ export function createAwsCliPolicy(config: AwsCliPolicyConfig = {}): Policy { name: "Allow AWS CLI", description: "Auto-allows non-destructive AWS CLI commands with ReadOnly profiles; requires approval for Admin profiles; denies destructive commands", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens || tokens[0] !== "aws") return next(); + if (!tokens || tokens[0] !== "aws") return; const profile = extractProfile(tokens); // Destructive commands — always require approval - if (isDestructiveCommand(tokens, extraDestructive)) return next(); + if (isDestructiveCommand(tokens, extraDestructive)) return; // Restricted account ID mentioned — require approval - if (mentionsRestrictedAccount(tokens, restrictedIds)) return next(); + if (mentionsRestrictedAccount(tokens, restrictedIds)) return; // ReadOnly profile → auto-allow non-destructive commands - if (profile && matchesAny(profile, readOnlyPatterns)) return allow(); + if (profile && matchesAny(profile, readOnlyPatterns)) return true; // Admin profile → always require approval - if (profile && matchesAny(profile, adminPatterns)) return next(); + if (profile && matchesAny(profile, adminPatterns)) return; // No profile or unrecognised profile → fall through to prompt - return next(); + return; }, }; } diff --git a/policies/allow-bash-find-in-project.ts b/policies/allow-bash-find-in-project.ts deleted file mode 100644 index b21ac2f..0000000 --- a/policies/allow-bash-find-in-project.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { resolve } from "node:path"; -import { allow, next, isWithinProject, type Policy } from "../src"; -import { parseShell, getPipelineCommands, getArgs, isSafeFilter } from "./parse-bash-ast"; - -const allowBashFindInProject: Policy = { - name: "Allow bash find in project", - description: "Permits find commands when all paths are within the project root", - handler: async (call) => { - if (call.tool !== "Bash") return next(); - if (typeof call.args.command !== "string") return next(); - if (!call.context.projectRoot) return next(); - - const ast = await parseShell(call.args.command); - if (!ast || ast.Stmts.length !== 1) return next(); - - const cmds = getPipelineCommands(ast.Stmts[0]); - if (!cmds) return next(); - - const tokens = getArgs(cmds[0]); - if (!tokens || tokens[0] !== "find") return next(); - - for (let i = 1; i < cmds.length; i++) { - const args = getArgs(cmds[i]); - if (!args || !isSafeFilter(args)) return next(); - } - - const SAFE_FLAGS = new Set([ - "-print", "-print0", "-ls", - "-name", "-iname", "-path", "-ipath", "-regex", "-iregex", - "-type", "-size", "-empty", "-newer", "-perm", "-user", "-group", - "-mtime", "-atime", "-ctime", "-mmin", "-amin", "-cmin", - "-readable", "-writable", "-executable", - "-maxdepth", "-mindepth", - "-not", "-and", "-or", "-a", "-o", "!", - "-follow", "-xdev", "-mount", "-daystart", - "-true", "-false", "-prune", - ]); - - for (const t of tokens.slice(1)) { - if (t.startsWith("-") && !SAFE_FLAGS.has(t)) return next(); - if (t === "(" || t === ")" || t === "\\(" || t === "\\)") return next(); - } - - const root = call.context.projectRoot; - const args = tokens.slice(1); - const paths: string[] = []; - for (const arg of args) { - if (arg.startsWith("-") || arg === "!" || arg === "(") break; - paths.push(arg); - } - - if (paths.length === 0) { - return isWithinProject(call.context.cwd, call.context) ? allow() : next(); - } - - const allInProject = paths.every((p) => { - const resolved = resolve(call.context.cwd, p); - return isWithinProject(resolved, call.context); - }); - - return allInProject ? allow() : next(); - }, -}; -export default allowBashFindInProject; diff --git a/policies/allow-bash-find.ts b/policies/allow-bash-find.ts new file mode 100644 index 0000000..ff10a20 --- /dev/null +++ b/policies/allow-bash-find.ts @@ -0,0 +1,80 @@ +import { resolve } from "node:path"; +import { homedir } from "node:os"; +import type { Policy } from "../src"; +import { parseShell, getPipelineCommands, getArgs, isSafeFilter } from "./parse-bash-ast"; + +const HOME = homedir(); + +function resolveHome(p: string): string { + if (p === "~") return HOME; + if (p.startsWith("~/")) return HOME + p.slice(1); + return p; +} + +function isUnderHome(path: string, cwd: string): string | null { + const expanded = resolveHome(path); + const resolved = resolve(cwd, expanded); + if (resolved === HOME || resolved.startsWith(HOME + "/")) return resolved; + return null; +} + +/** + * Allow `find` when all search paths resolve to somewhere under $HOME. + * Dangerous flags (-exec, -delete, -ok, -fprint*, etc.) are rejected at the + * AST level. Pipelines must consist only of safe filters. + */ +const allowBashFind: Policy = { + name: "Allow bash find", + description: "Permits find commands when all search paths are under $HOME", + action: "allow", + handler: async (call) => { + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; + + const ast = await parseShell(call.args.command); + if (!ast || ast.Stmts.length !== 1) return; + + const cmds = getPipelineCommands(ast.Stmts[0]); + if (!cmds) return; + + const tokens = getArgs(cmds[0]); + if (!tokens || tokens[0] !== "find") return; + + for (let i = 1; i < cmds.length; i++) { + const args = getArgs(cmds[i]); + if (!args || !isSafeFilter(args)) return; + } + + const SAFE_FLAGS = new Set([ + "-print", "-print0", "-ls", + "-name", "-iname", "-path", "-ipath", "-regex", "-iregex", + "-type", "-size", "-empty", "-newer", "-perm", "-user", "-group", + "-mtime", "-atime", "-ctime", "-mmin", "-amin", "-cmin", + "-readable", "-writable", "-executable", + "-maxdepth", "-mindepth", + "-not", "-and", "-or", "-a", "-o", "!", + "-follow", "-xdev", "-mount", "-daystart", + "-true", "-false", "-prune", + ]); + + for (const t of tokens.slice(1)) { + if (t.startsWith("-") && !SAFE_FLAGS.has(t)) return; + if (t === "(" || t === ")" || t === "\\(" || t === "\\)") return; + } + + const args = tokens.slice(1); + const paths: string[] = []; + for (const arg of args) { + if (arg.startsWith("-") || arg === "!" || arg === "(") break; + paths.push(arg); + } + + if (paths.length === 0) { + return isUnderHome(call.context.cwd, call.context.cwd) ? true : undefined; + } + + const allUnderHome = paths.every((p) => isUnderHome(p, call.context.cwd) !== null); + return allUnderHome ? true : undefined; + }, +}; +export default allowBashFind; diff --git a/policies/allow-bash-grep-in-project.ts b/policies/allow-bash-grep-in-project.ts index 8a759c7..3cb8948 100644 --- a/policies/allow-bash-grep-in-project.ts +++ b/policies/allow-bash-grep-in-project.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, getPipelineCommands, getArgs, isSafeFilter } from "./parse-bash-ast"; const GREP_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg"]); @@ -7,24 +7,42 @@ const GREP_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg"]); const allowBashGrepInProject: Policy = { name: "Allow bash grep in project", description: "Permits grep/egrep/fgrep/rg commands when all paths are within the project root", + action: "allow", handler: async (call) => { - if (call.tool !== "Bash") return next(); - if (typeof call.args.command !== "string") return next(); - if (!call.context.projectRoot) return next(); + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; + if (!call.context.projectRoot) return; const ast = await parseShell(call.args.command); - if (!ast || ast.Stmts.length !== 1) return next(); + if (!ast || ast.Stmts.length !== 1) return; const cmds = getPipelineCommands(ast.Stmts[0]); - if (!cmds || cmds.length === 0) return next(); + if (!cmds || cmds.length === 0) return; const tokens = getArgs(cmds[0]); - if (!tokens || !GREP_COMMANDS.has(tokens[0])) return next(); + if (!tokens || !GREP_COMMANDS.has(tokens[0])) return; + + // For rg specifically, reject flags that execute commands or read arbitrary files + if (tokens[0] === "rg") { + const blockedExact = [ + "--pre", "--preprocessor", "--pre-glob", "--hostname-bin", + "-z", "--search-zip", + "-f", "--file", "--ignore-file", + ]; + const blockedPrefix = [ + "--pre=", "--preprocessor=", "--pre-glob=", "--hostname-bin=", + "--file=", "--ignore-file=", + ]; + for (const t of tokens.slice(1)) { + if (blockedExact.includes(t)) return; + if (blockedPrefix.some((p) => t.startsWith(p))) return; + } + } // All subsequent pipeline segments must be safe filters for (let i = 1; i < cmds.length; i++) { const segArgs = getArgs(cmds[i]); - if (!segArgs || !isSafeFilter(segArgs)) return next(); + if (!segArgs || !isSafeFilter(segArgs)) return; } const root = call.context.projectRoot; @@ -35,9 +53,9 @@ const allowBashGrepInProject: Policy = { // With no path args, grep reads stdin or cwd — allow if cwd is in project if (nonFlags.length === 0) { if (call.context.cwd.startsWith(root + "/") || call.context.cwd === root) { - return allow(); + return true; } - return next(); + return; } // First non-flag is the pattern; rest are file/dir paths @@ -46,9 +64,9 @@ const allowBashGrepInProject: Policy = { // No explicit paths — defaults to cwd if (paths.length === 0) { if (call.context.cwd.startsWith(root + "/") || call.context.cwd === root) { - return allow(); + return true; } - return next(); + return; } const allInProject = paths.every((p) => { @@ -56,7 +74,7 @@ const allowBashGrepInProject: Policy = { return resolved === root || resolved.startsWith(root + "/"); }); - return allInProject ? allow() : next(); + return allInProject ? true : undefined; }, }; export default allowBashGrepInProject; diff --git a/policies/allow-brew.ts b/policies/allow-brew.ts index a795716..f8a8305 100644 --- a/policies/allow-brew.ts +++ b/policies/allow-brew.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; /** Brew subcommands that are purely read-only / informational */ @@ -43,12 +43,13 @@ const allowBrew: Policy = { name: "Allow brew read-only", description: "Auto-allows read-only brew commands (list, info, search, etc.); requires approval for install, uninstall, upgrade, and other mutating commands", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens || tokens[0] !== "brew") return next(); + if (!tokens || tokens[0] !== "brew") return; // Check for safe flag-subcommands (e.g. brew --version, brew --prefix) - if (tokens.length >= 2 && SAFE_FLAGS.has(tokens[1])) return allow(); + if (tokens.length >= 2 && SAFE_FLAGS.has(tokens[1])) return true; // Find the subcommand (first non-flag token after "brew") let subcommand: string | undefined; @@ -58,20 +59,20 @@ const allowBrew: Policy = { break; } } - if (!subcommand) return next(); + if (!subcommand) return; // "brew services list" is safe, "brew services start/stop/restart" is not if (subcommand === "services") { const servicesAction = tokens.find( (t, i) => i > tokens.indexOf("services") && !t.startsWith("-"), ); - if (servicesAction && SAFE_SERVICES_SUBCOMMANDS.has(servicesAction)) return allow(); - return next(); + if (servicesAction && SAFE_SERVICES_SUBCOMMANDS.has(servicesAction)) return true; + return; } - if (SAFE_SUBCOMMANDS.has(subcommand)) return allow(); + if (SAFE_SUBCOMMANDS.has(subcommand)) return true; - return next(); + return; }, }; export default allowBrew; diff --git a/policies/allow-bun-test.ts b/policies/allow-bun-test.ts index cb0cbaa..92069e9 100644 --- a/policies/allow-bun-test.ts +++ b/policies/allow-bun-test.ts @@ -1,14 +1,15 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const allowBunTest: Policy = { name: "Allow bun test", description: "Permits bun test commands, optionally piped through safe filters", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] === "bun" && tokens[1] === "test") return allow(); - return next(); + if (!tokens) return; + if (tokens[0] === "bun" && tokens[1] === "test") return true; + return; }, }; export default allowBunTest; diff --git a/policies/allow-cd-in-project.ts b/policies/allow-cd-in-project.ts index 13f2a3d..9c0da99 100644 --- a/policies/allow-cd-in-project.ts +++ b/policies/allow-cd-in-project.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; import { parseShell, getArgs } from "./parse-bash-ast"; function expandTilde(p: string): string { @@ -13,25 +13,26 @@ const allowCdInProject: Policy = { name: "Allow cd within project", description: "Permits standalone cd commands when the target is within the project root", + action: "allow", handler: async (call) => { - if (call.tool !== "Bash") return next(); - if (typeof call.args.command !== "string") return next(); - if (!call.context.projectRoot) return next(); + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; + if (!call.context.projectRoot) return; const ast = await parseShell(call.args.command); - if (!ast || ast.Stmts.length !== 1) return next(); + if (!ast || ast.Stmts.length !== 1) return; const args = getArgs(ast.Stmts[0]); - if (!args || args[0] !== "cd") return next(); + if (!args || args[0] !== "cd") return; const root = call.context.projectRoot; // bare `cd` (goes to home) — not in project - if (args.length === 1) return next(); + if (args.length === 1) return; // resolve the target path relative to cwd, expanding ~ first const target = resolve(call.context.cwd, expandTilde(args[1])); - return isWithinProject(target, call.context) ? allow() : next(); + return isWithinProject(target, call.context) ? true : undefined; }, }; export default allowCdInProject; diff --git a/policies/allow-cron-crud.ts b/policies/allow-cron-crud.ts index 92fb555..150a48c 100644 --- a/policies/allow-cron-crud.ts +++ b/policies/allow-cron-crud.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; const CRON_TOOLS = new Set(["CronCreate", "CronDelete", "CronList"]); @@ -8,12 +8,13 @@ const CRON_TOOLS = new Set(["CronCreate", "CronDelete", "CronList"]); const allowCronCrud: Policy = { name: "Allow Cron CRUD", description: "Permits CronCreate, CronDelete, and CronList tool calls", + action: "allow", handler: async (call) => { if (!CRON_TOOLS.has(call.tool)) { - return next(); + return; } - return allow(); + return true; }, }; export default allowCronCrud; diff --git a/policies/allow-date.ts b/policies/allow-date.ts new file mode 100644 index 0000000..eb8779a --- /dev/null +++ b/policies/allow-date.ts @@ -0,0 +1,81 @@ +import type { Policy } from "../src"; +import { + parseShell, + hasUnsafeNodes, + getArgs, + getAndChainSegments, +} from "./parse-bash-ast"; + +/** + * Flags that take their value as the next token (space-separated form). + * Their values are user-supplied strings/paths but the command itself is + * read-only, so the values don't need to be validated. + */ +const FLAGS_WITH_ARG = new Set([ + "-d", + "--date", + "-f", + "--file", + "-r", + "--reference", +]); + +function isSafeDateInvocation(tokens: string[]): boolean { + if (tokens[0] !== "date") return false; + + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + + // Reject any form that sets the system clock + if (t === "-s" || t === "--set" || t.startsWith("--set=")) return false; + + // Format string (+%Y-%m-%d etc.) — safe + if (t.startsWith("+")) continue; + + // Long flag with = value (e.g. --rfc-3339=seconds, --date=...) + if (t.startsWith("--") && t.includes("=")) continue; + + // Space-separated flag-with-value — skip the value token + if (FLAGS_WITH_ARG.has(t)) { + i++; + continue; + } + + // Bare flag — safe + if (t.startsWith("-")) continue; + + // Bare positional argument (not a +format, not a flag) — this is the + // syntax for setting the clock, e.g. `date 010100002025`. Reject. + return false; + } + + return true; +} + +const allowDate: Policy = { + name: "Allow date", + description: + "Permits the date command (and && chains of date commands) for reading and formatting time; rejects forms that set the system clock", + action: "allow", + handler: async (call) => { + if (call.tool !== "Bash") return; + const command = call.args?.command; + if (typeof command !== "string") return; + + const file = await parseShell(command); + if (!file) return; + if (hasUnsafeNodes(file)) return; + + const segments = getAndChainSegments(file); + if (!segments) return; + + for (const segment of segments) { + const args = getArgs(segment); + if (!args) return; + if (!isSafeDateInvocation(args)) return; + } + + return true; + }, +}; +export default allowDate; diff --git a/policies/allow-edit-in-project.ts b/policies/allow-edit-in-project.ts index 2314268..4598b6b 100644 --- a/policies/allow-edit-in-project.ts +++ b/policies/allow-edit-in-project.ts @@ -1,6 +1,6 @@ import { basename } from "path"; import { homedir } from "os"; -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; function resolveHome(p: string): string { if (p === "~") return homedir(); @@ -47,22 +47,23 @@ function isSensitive(filePath: string, context: { projectRoot: string; additiona const allowEditInProject: Policy = { name: "Allow edits in project", description: "Auto-allows Edit, Write, and Update tool calls targeting files inside the project root, except sensitive files", + action: "allow", handler: async (call) => { - if (call.tool !== "Edit" && call.tool !== "Write" && call.tool !== "Update") return next(); + if (call.tool !== "Edit" && call.tool !== "Write" && call.tool !== "Update") return; const filePath = call.args.file_path; - if (typeof filePath !== "string") return next(); + if (typeof filePath !== "string") return; const projectRoot = call.context.projectRoot; - if (!projectRoot) return next(); + if (!projectRoot) return; const resolved = resolveHome(filePath); if (isWithinProject(resolved, call.context)) { - if (isSensitive(resolved, call.context)) return next(); - return allow(); + if (isSensitive(resolved, call.context)) return; + return true; } - return next(); + return; }, }; export default allowEditInProject; diff --git a/policies/allow-explore-in-project.ts b/policies/allow-explore-in-project.ts index db03b1c..992cbfe 100644 --- a/policies/allow-explore-in-project.ts +++ b/policies/allow-explore-in-project.ts @@ -1,4 +1,4 @@ -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; /** * Allow the Explore agent, but only when invoked within the project directory. @@ -6,20 +6,21 @@ import { allow, next, isWithinProject, type Policy } from "../src"; const allowExploreInProject: Policy = { name: "Allow explore in project", description: "Permits the Explore agent when cwd is within the project root", + action: "allow", handler: async (call) => { if (call.tool !== "Agent") { - return next(); + return; } if (call.args.subagent_type !== "Explore") { - return next(); + return; } if (!call.context.projectRoot || !isWithinProject(call.context.cwd, call.context)) { - return next(); + return; } - return allow(); + return true; }, }; export default allowExploreInProject; diff --git a/policies/allow-find-in-project.ts b/policies/allow-find-in-project.ts index 0694a1f..f885ed9 100644 --- a/policies/allow-find-in-project.ts +++ b/policies/allow-find-in-project.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; /** * Allow Find tool calls when the search path is within the project root. @@ -8,29 +8,30 @@ import { allow, next, isWithinProject, type Policy } from "../src"; const allowFindInProject: Policy = { name: "Allow find in project", description: "Permits Find tool calls targeting paths within the project root", + action: "allow", handler: async (call) => { if (call.tool !== "Find") { - return next(); + return; } if (!call.context.projectRoot) { - return next(); + return; } const searchPath = call.args.path; // No path specified — Find defaults to cwd if (searchPath === undefined) { - return isWithinProject(call.context.cwd, call.context) ? allow() : next(); + return isWithinProject(call.context.cwd, call.context) ? true : undefined; } if (typeof searchPath !== "string") { - return next(); + return; } // Resolve relative paths against cwd const resolved = resolve(call.context.cwd, searchPath); - return isWithinProject(resolved, call.context) ? allow() : next(); + return isWithinProject(resolved, call.context) ? true : undefined; }, }; export default allowFindInProject; diff --git a/policies/allow-gh-read-only.ts b/policies/allow-gh-read-only.ts index 0d4aa7d..0566f28 100644 --- a/policies/allow-gh-read-only.ts +++ b/policies/allow-gh-read-only.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const readOnlySubcommands: Record> = { @@ -13,28 +13,29 @@ const readOnlySubcommands: Record> = { const allowGhReadOnly: Policy = { name: "Allow gh read-only", description: "Permits read-only gh CLI commands (view, list, diff, checks, search)", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] !== "gh") return next(); + if (!tokens) return; + if (tokens[0] !== "gh") return; const command = tokens[1]; const subcommand = tokens[2]; const allowed = readOnlySubcommands[command]; - if (allowed && subcommand && allowed.has(subcommand)) return allow(); + if (allowed && subcommand && allowed.has(subcommand)) return true; if (command === "api") { const mutatingFlags = new Set(["-X", "--method", "-f", "-F", "--field", "--raw-field", "--input"]); for (let i = 2; i < tokens.length; i++) { const t = tokens[i]; - if (mutatingFlags.has(t)) return next(); - if (t.startsWith("-X") && t.length > 2) return next(); + if (mutatingFlags.has(t)) return; + if (t.startsWith("-X") && t.length > 2) return; } - return allow(); + return true; } - return next(); + return; }, }; export default allowGhReadOnly; diff --git a/policies/allow-git-add.ts b/policies/allow-git-add.ts index 7e763df..797492c 100644 --- a/policies/allow-git-add.ts +++ b/policies/allow-git-add.ts @@ -1,14 +1,15 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const allowGitAdd: Policy = { name: "Allow git add", description: "Permits git add commands, optionally piped through safe filters", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] === "git" && tokens[1] === "add") return allow(); - return next(); + if (!tokens) return; + if (tokens[0] === "git" && tokens[1] === "add") return true; + return; }, }; export default allowGitAdd; diff --git a/policies/allow-git-branch.ts b/policies/allow-git-branch.ts index f45c971..b6dca49 100644 --- a/policies/allow-git-branch.ts +++ b/policies/allow-git-branch.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; /** Flags that only read/list branches — no mutations. */ @@ -67,15 +67,16 @@ const allowReadOnlyGitBranch: Policy = { name: "Allow git branch (read-only)", description: "Permits read-only git branch commands (list, show-current, filtering) while blocking branch creation/deletion/rename", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] !== "git" || tokens[1] !== "branch") return next(); + if (!tokens) return; + if (tokens[0] !== "git" || tokens[1] !== "branch") return; const args = tokens.slice(2); // Bare `git branch` (lists local branches) is safe - if (args.length === 0) return allow(); + if (args.length === 0) return true; let hasReadonlyFlag = false; let i = 0; @@ -85,7 +86,7 @@ const allowReadOnlyGitBranch: Policy = { // Handle --flag=value style const flagName = arg.includes("=") ? arg.split("=")[0] : arg; - if (mutationFlags.has(flagName)) return next(); + if (mutationFlags.has(flagName)) return; if (readonlyFlags.has(flagName)) { hasReadonlyFlag = true; @@ -103,15 +104,15 @@ const allowReadOnlyGitBranch: Policy = { // Only safe if we already saw a flag that takes a branch pattern // (like --contains , --merged ) — those are handled above. // A bare positional means `git branch ` = create branch. - return next(); + return; } // Unknown flag — don't allow - return next(); + return; } - if (hasReadonlyFlag || args.length === 0) return allow(); - return next(); + if (hasReadonlyFlag || args.length === 0) return true; + return; }, }; export default allowReadOnlyGitBranch; diff --git a/policies/allow-git-check-ignore.ts b/policies/allow-git-check-ignore.ts index aaa4cc5..ee54b02 100644 --- a/policies/allow-git-check-ignore.ts +++ b/policies/allow-git-check-ignore.ts @@ -1,14 +1,15 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const allowGitCheckIgnore: Policy = { name: "Allow git check-ignore", description: "Permits git check-ignore commands, optionally piped through safe filters", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] === "git" && tokens[1] === "check-ignore") return allow(); - return next(); + if (!tokens) return; + if (tokens[0] === "git" && tokens[1] === "check-ignore") return true; + return; }, }; export default allowGitCheckIgnore; diff --git a/policies/allow-git-checkout-b.ts b/policies/allow-git-checkout-b.ts index bd2b475..3c22934 100644 --- a/policies/allow-git-checkout-b.ts +++ b/policies/allow-git-checkout-b.ts @@ -1,28 +1,29 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; const allowGitCheckoutB: Policy = { name: "Allow git checkout -b", description: "Permits git checkout -b to create and switch to a new branch", + action: "allow", handler: async (call) => { const tokens = await safeBashCommand(call); - if (!tokens) return next(); - if (tokens[0] !== "git" || tokens[1] !== "checkout") return next(); + if (!tokens) return; + if (tokens[0] !== "git" || tokens[1] !== "checkout") return; const args = tokens.slice(2); // git checkout -b or git checkout -b - if (args.length < 2 || args.length > 3) return next(); - if (args[0] !== "-b") return next(); + if (args.length < 2 || args.length > 3) return; + if (args[0] !== "-b") return; // Branch name must not start with a dash - if (args[1].startsWith("-")) return next(); + if (args[1].startsWith("-")) return; // Optional start-point must not start with a dash - if (args.length === 3 && args[2].startsWith("-")) return next(); + if (args.length === 3 && args[2].startsWith("-")) return; - return allow(); + return true; }, }; export default allowGitCheckoutB; diff --git a/policies/allow-git-commit.ts b/policies/allow-git-commit.ts index 1933f42..095ada1 100644 --- a/policies/allow-git-commit.ts +++ b/policies/allow-git-commit.ts @@ -1,14 +1,15 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; const allowGitCommit: Policy = { name: "Allow git commit", description: "Permits standalone git commit commands (chained add+commit is caught by deny policy)", + action: "allow", handler: async (call) => { const tokens = await safeBashCommand(call); - if (!tokens) return next(); - if (tokens[0] === "git" && tokens[1] === "commit") return allow(); - return next(); + if (!tokens) return; + if (tokens[0] === "git" && tokens[1] === "commit") return true; + return; }, }; export default allowGitCommit; diff --git a/policies/allow-git-diff.ts b/policies/allow-git-diff.ts index b1ce6c3..eeb01df 100644 --- a/policies/allow-git-diff.ts +++ b/policies/allow-git-diff.ts @@ -1,14 +1,15 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const allowGitDiff: Policy = { name: "Allow git diff", description: "Permits git diff commands, optionally piped through safe filters", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] === "git" && tokens[1] === "diff") return allow(); - return next(); + if (!tokens) return; + if (tokens[0] === "git" && tokens[1] === "diff") return true; + return; }, }; export default allowGitDiff; diff --git a/policies/allow-git-local-repo.ts b/policies/allow-git-local-repo.ts index 8805206..345a6df 100644 --- a/policies/allow-git-local-repo.ts +++ b/policies/allow-git-local-repo.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; /** Cache remote check results per projectRoot for the process lifetime. */ @@ -63,19 +63,20 @@ const allowGitLocalRepo: Policy = { name: "Allow git in local repos", description: "Auto-approves git operations in repos with no configured remotes, except commands that discard uncommitted work", + action: "allow", handler: async (call) => { const tokens = await safeBashCommand(call); - if (!tokens) return next(); - if (tokens[0] !== "git") return next(); + if (!tokens) return; + if (tokens[0] !== "git") return; const projectRoot = call.context.projectRoot; - if (!projectRoot) return next(); + if (!projectRoot) return; - if (!(await isLocalRepo(projectRoot))) return next(); + if (!(await isLocalRepo(projectRoot))) return; - if (isDestructiveGit(tokens)) return next(); + if (isDestructiveGit(tokens)) return; - return allow(); + return true; }, }; export default allowGitLocalRepo; diff --git a/policies/allow-git-log.ts b/policies/allow-git-log.ts index 8d9efd1..72f3619 100644 --- a/policies/allow-git-log.ts +++ b/policies/allow-git-log.ts @@ -1,16 +1,17 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const allowGitLog: Policy = { name: "Allow git log/show", description: "Permits git log and git show commands, optionally piped through safe filters", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); + if (!tokens) return; if (tokens[0] === "git" && (tokens[1] === "log" || tokens[1] === "show")) - return allow(); - return next(); + return true; + return; }, }; export default allowGitLog; diff --git a/policies/allow-git-rev-parse.ts b/policies/allow-git-rev-parse.ts index 72dfd70..b0e1ec2 100644 --- a/policies/allow-git-rev-parse.ts +++ b/policies/allow-git-rev-parse.ts @@ -1,14 +1,15 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const allowGitRevParse: Policy = { name: "Allow git rev-parse", description: "Permits git rev-parse commands, optionally piped through safe filters", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] === "git" && tokens[1] === "rev-parse") return allow(); - return next(); + if (!tokens) return; + if (tokens[0] === "git" && tokens[1] === "rev-parse") return true; + return; }, }; export default allowGitRevParse; diff --git a/policies/allow-git-stash.ts b/policies/allow-git-stash.ts index f064ba7..df831ea 100644 --- a/policies/allow-git-stash.ts +++ b/policies/allow-git-stash.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; /** @@ -10,15 +10,16 @@ const allowGitStash: Policy = { name: "Allow safe git stash", description: "Permits git stash commands except destructive ones (drop, clear)", + action: "allow", handler: async (call) => { const tokens = await safeBashCommand(call); - if (!tokens) return next(); - if (tokens[0] !== "git" || tokens[1] !== "stash") return next(); + if (!tokens) return; + if (tokens[0] !== "git" || tokens[1] !== "stash") return; const sub = tokens[2]; - if (sub && DESTRUCTIVE.has(sub)) return next(); + if (sub && DESTRUCTIVE.has(sub)) return; - return allow(); + return true; }, }; export default allowGitStash; diff --git a/policies/allow-git-status.ts b/policies/allow-git-status.ts index eaaa961..f6d006d 100644 --- a/policies/allow-git-status.ts +++ b/policies/allow-git-status.ts @@ -1,14 +1,15 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; const allowGitStatus: Policy = { name: "Allow git status", description: "Permits git status commands, optionally piped through safe filters", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] === "git" && tokens[1] === "status") return allow(); - return next(); + if (!tokens) return; + if (tokens[0] === "git" && tokens[1] === "status") return true; + return; }, }; export default allowGitStatus; diff --git a/policies/allow-git-worktree.ts b/policies/allow-git-worktree.ts index 371a5b6..f5dcd0f 100644 --- a/policies/allow-git-worktree.ts +++ b/policies/allow-git-worktree.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; const SAFE_SUBCOMMANDS = new Set(["add", "list", "move", "remove", "prune", "lock", "unlock", "repair"]); @@ -6,13 +6,14 @@ const SAFE_SUBCOMMANDS = new Set(["add", "list", "move", "remove", "prune", "loc const allowGitWorktree: Policy = { name: "Allow git worktree CRUD", description: "Permits git worktree add/list/move/remove/prune/lock/unlock/repair", + action: "allow", handler: async (call) => { const tokens = await safeBashCommand(call); - if (!tokens) return next(); + if (!tokens) return; if (tokens[0] === "git" && tokens[1] === "worktree" && SAFE_SUBCOMMANDS.has(tokens[2])) { - return allow(); + return true; } - return next(); + return; }, }; export default allowGitWorktree; diff --git a/policies/allow-grep-in-project.ts b/policies/allow-grep-in-project.ts index ab63737..9786132 100644 --- a/policies/allow-grep-in-project.ts +++ b/policies/allow-grep-in-project.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; /** * Allow Grep tool calls when the search path is within the project root. @@ -8,29 +8,30 @@ import { allow, next, isWithinProject, type Policy } from "../src"; const allowGrepInProject: Policy = { name: "Allow grep in project", description: "Permits Grep tool calls targeting paths within the project root", + action: "allow", handler: async (call) => { if (call.tool !== "Grep") { - return next(); + return; } if (!call.context.projectRoot) { - return next(); + return; } const searchPath = call.args.path; // No path specified — Grep defaults to cwd if (searchPath === undefined) { - return isWithinProject(call.context.cwd, call.context) ? allow() : next(); + return isWithinProject(call.context.cwd, call.context) ? true : undefined; } if (typeof searchPath !== "string") { - return next(); + return; } // Resolve relative paths against cwd const resolved = resolve(call.context.cwd, searchPath); - return isWithinProject(resolved, call.context) ? allow() : next(); + return isWithinProject(resolved, call.context) ? true : undefined; }, }; export default allowGrepInProject; diff --git a/policies/allow-ls-in-project.ts b/policies/allow-ls-in-project.ts deleted file mode 100644 index 27da2bf..0000000 --- a/policies/allow-ls-in-project.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { allow, next, isWithinProject, type Policy } from "../src"; -import { parseShell, getPipelineCommands, getArgs, isSafeFilter } from "./parse-bash-ast"; - -const allowLsInProject: Policy = { - name: "Allow ls in project", - description: "Permits ls commands when all paths are within the project root", - handler: async (call) => { - if (call.tool !== "Bash") return next(); - if (typeof call.args.command !== "string") return next(); - if (!call.context.projectRoot) return next(); - - const ast = await parseShell(call.args.command); - if (!ast || ast.Stmts.length !== 1) return next(); - - const cmds = getPipelineCommands(ast.Stmts[0]); - if (!cmds || cmds.length === 0) return next(); - - const tokens = getArgs(cmds[0]); - if (!tokens || tokens[0] !== "ls") return next(); - - // All subsequent pipeline segments must be safe filters - for (let i = 1; i < cmds.length; i++) { - const segArgs = getArgs(cmds[i]); - if (!segArgs || !isSafeFilter(segArgs)) return next(); - } - - const root = call.context.projectRoot; - const paths = tokens.slice(1).filter((t) => !t.startsWith("-")); - - if (paths.length === 0) { - return isWithinProject(call.context.cwd, call.context) ? allow() : next(); - } - - const allInProject = paths.every((p) => { - // Relative paths are resolved against cwd which is already in-project - if (p.startsWith("./") || p === "." || !p.startsWith("/")) return true; - return isWithinProject(p, call.context); - }); - - return allInProject ? allow() : next(); - }, -}; -export default allowLsInProject; diff --git a/policies/allow-ls.ts b/policies/allow-ls.ts new file mode 100644 index 0000000..a59aff0 --- /dev/null +++ b/policies/allow-ls.ts @@ -0,0 +1,61 @@ +import { homedir } from "node:os"; +import type { Policy } from "../src"; +import { parseShell, getPipelineCommands, getArgs, isSafeFilter } from "./parse-bash-ast"; + +const HOME = homedir(); + +function resolveHome(p: string): string { + if (p === "~") return HOME; + if (p.startsWith("~/")) return HOME + p.slice(1); + return p; +} + +/** + * Returns true if the path contains any segment starting with `.` other than + * `.` (cwd) or `..` (parent). Listing dot-prefixed dirs (`~/.ssh`, `.aws`, + * `.gnupg`, project `.env` siblings, etc.) tends to reveal secrets or + * tooling state the user hasn't opted into sharing. + */ +function hasDotPrefixedSegment(path: string): boolean { + const expanded = resolveHome(path); + for (const seg of expanded.split("/")) { + if (seg === "" || seg === "." || seg === "..") continue; + if (seg.startsWith(".")) return true; + } + return false; +} + +/** + * Allow `ls` against any path that contains no dot-prefixed segments. The + * AST parser blocks command substitution, redirects to non-/dev targets, + * backgrounding, and chaining. Pipelines must consist only of safe filters. + */ +const allowLs: Policy = { + name: "Allow ls", + description: "Permits ls against paths with no dot-prefixed segments, optionally piped through safe filters", + action: "allow", + handler: async (call) => { + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; + + const ast = await parseShell(call.args.command); + if (!ast || ast.Stmts.length !== 1) return; + + const cmds = getPipelineCommands(ast.Stmts[0]); + if (!cmds || cmds.length === 0) return; + + const tokens = getArgs(cmds[0]); + if (!tokens || tokens[0] !== "ls") return; + + for (let i = 1; i < cmds.length; i++) { + const segArgs = getArgs(cmds[i]); + if (!segArgs || !isSafeFilter(segArgs)) return; + } + + const paths = tokens.slice(1).filter((t) => !t.startsWith("-")); + if (paths.some(hasDotPrefixedSegment)) return; + + return true; + }, +}; +export default allowLs; diff --git a/policies/allow-lsof.ts b/policies/allow-lsof.ts new file mode 100644 index 0000000..0963372 --- /dev/null +++ b/policies/allow-lsof.ts @@ -0,0 +1,15 @@ +import type { Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; + +const allowLsof: Policy = { + name: "Allow lsof", + description: "Permits lsof for inspecting open files, sockets, and processes; optionally piped through safe filters", + action: "allow", + handler: async (call) => { + const tokens = await safeBashCommandOrPipeline(call); + if (!tokens) return; + if (tokens[0] !== "lsof") return; + return true; + }, +}; +export default allowLsof; diff --git a/policies/allow-magick-in-project.ts b/policies/allow-magick-in-project.ts new file mode 100644 index 0000000..2b14c71 --- /dev/null +++ b/policies/allow-magick-in-project.ts @@ -0,0 +1,91 @@ +import { resolve } from "node:path"; +import { isWithinProject, type Policy } from "../src"; +import { safeBashCommand } from "./parse-bash-ast"; + +const ZERO_ARG_FLAGS = new Set([ + "-version", + "-negate", + "-separate", + "+repage", +]); + +const ONE_ARG_FLAGS = new Set([ + "-threshold", + "-fuzz", + "+opaque", + "-channel", + "-resize", + "-crop", + "-gravity", + "-quality", + "-connected-components", +]); + +const PSEUDO_OUTPUTS = new Set(["info:", "null:"]); + +const SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/; + +const allowMagickInProject: Policy = { + name: "Allow magick in project", + description: + "Permits ImageMagick (magick) with a conservative flag allowlist when all input and output paths are within the project root", + action: "allow", + handler: async (call) => { + const args = await safeBashCommand(call); + if (!args || args[0] !== "magick") return; + if (!call.context.projectRoot) return; + + const positionals: string[] = []; + let sawVersion = false; + + let i = 1; + while (i < args.length) { + const a = args[i]; + + if (a === "-version") { + sawVersion = true; + i++; + continue; + } + if (ZERO_ARG_FLAGS.has(a)) { + i++; + continue; + } + if (ONE_ARG_FLAGS.has(a)) { + if (i + 1 >= args.length) return; + i += 2; + continue; + } + if (a.startsWith("-") || a.startsWith("+")) return; + + positionals.push(a); + i++; + } + + if (sawVersion) { + return positionals.length === 0 ? true : undefined; + } + + if (positionals.length < 2) return; + + const output = positionals[positionals.length - 1]; + const inputs = positionals.slice(0, -1); + + for (const p of inputs) { + if (p.startsWith("@")) return; + if (SCHEME_PREFIX.test(p)) return; + if (p.startsWith("~")) return; + const resolved = resolve(call.context.cwd, p); + if (!isWithinProject(resolved, call.context)) return; + } + + if (PSEUDO_OUTPUTS.has(output)) return true; + if (output.startsWith("@")) return; + if (SCHEME_PREFIX.test(output)) return; + if (output.startsWith("~")) return; + + const resolvedOut = resolve(call.context.cwd, output); + return isWithinProject(resolvedOut, call.context) ? true : undefined; + }, +}; +export default allowMagickInProject; diff --git a/policies/allow-mcp-context7.ts b/policies/allow-mcp-context7.ts index 8d2b7cf..5620f5c 100644 --- a/policies/allow-mcp-context7.ts +++ b/policies/allow-mcp-context7.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow all mcp__context7__* tool calls unconditionally. @@ -7,9 +7,10 @@ import { allow, next, type Policy } from "../src"; const allowMcpContext7: Policy = { name: "Allow MCP Context7", description: "Permits all Context7 documentation lookup tool calls", + action: "allow", handler: async (call) => { - if (!call.tool.startsWith("mcp__context7__")) return next(); - return allow(); + if (!call.tool.startsWith("mcp__context7__")) return; + return true; }, }; export default allowMcpContext7; diff --git a/policies/allow-mcp-ide-diagnostics.ts b/policies/allow-mcp-ide-diagnostics.ts index 3c9614f..4d8711f 100644 --- a/policies/allow-mcp-ide-diagnostics.ts +++ b/policies/allow-mcp-ide-diagnostics.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow all mcp__ide__getDiagnostics tool calls unconditionally. @@ -7,9 +7,10 @@ import { allow, next, type Policy } from "../src"; const allowMcpIdeDiagnostics: Policy = { name: "Allow MCP IDE Diagnostics", description: "Permits all mcp__ide__getDiagnostics tool calls", + action: "allow", handler: async (call) => { - if (call.tool !== "mcp__ide__getDiagnostics") return next(); - return allow(); + if (call.tool !== "mcp__ide__getDiagnostics") return; + return true; }, }; export default allowMcpIdeDiagnostics; diff --git a/policies/allow-mcp-playwright.ts b/policies/allow-mcp-playwright.ts index 0286fdf..edc219a 100644 --- a/policies/allow-mcp-playwright.ts +++ b/policies/allow-mcp-playwright.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow all mcp__playwright__* tool calls unconditionally. @@ -7,9 +7,10 @@ import { allow, next, type Policy } from "../src"; const allowMcpPlaywright: Policy = { name: "Allow MCP Playwright", description: "Permits all Playwright browser automation tool calls", + action: "allow", handler: async (call) => { - if (!call.tool.startsWith("mcp__playwright__")) return next(); - return allow(); + if (!call.tool.startsWith("mcp__playwright__")) return; + return true; }, }; export default allowMcpPlaywright; diff --git a/policies/allow-memory-crud.ts b/policies/allow-memory-crud.ts new file mode 100644 index 0000000..fa4d860 --- /dev/null +++ b/policies/allow-memory-crud.ts @@ -0,0 +1,89 @@ +import { join, resolve } from "node:path"; +import { homedir } from "node:os"; +import type { Policy } from "../src"; +import { safeBashCommand } from "./parse-bash-ast"; + +const HOME = homedir(); +const PROJECTS_DIR = join(HOME, ".claude", "projects"); + +function resolveHome(p: string): string { + if (p === "~") return HOME; + if (p.startsWith("~/")) return HOME + p.slice(1); + return p; +} + +/** + * Memory lives at ~/.claude/projects//memory/. + * Files are flat (no nested subdirs) and are owned by Claude — the agent + * authors and curates them itself. + */ +function isMemoryPath(path: string): boolean { + const expanded = resolveHome(path); + if (!expanded.startsWith(PROJECTS_DIR + "/")) return false; + const rel = expanded.slice(PROJECTS_DIR.length + 1); + return /^[^/]+\/memory\/[^/]+/.test(rel); +} + +/** Matches the memory dir itself or any file/path within it (with or without trailing slash). */ +function isMemoryDirOrChild(path: string): boolean { + const expanded = resolveHome(path).replace(/\/+$/, ""); + if (!expanded.startsWith(PROJECTS_DIR + "/")) return false; + const rel = expanded.slice(PROJECTS_DIR.length + 1); + return /^[^/]+\/memory(\/.+)?$/.test(rel); +} + +/** + * Allow Claude full CRUD on its own auto-memory files + * (~/.claude/projects/*\/memory/): + * - Read / Write / Edit / Update via the dedicated tools + * - `rm ` via Bash, no -r/-f (memory is a flat directory) + * - `ls` of the memory dir or files within it + */ +const allowMemoryCrud: Policy = { + name: "Allow CRUD on Claude memory", + description: + "Permits Read/Write/Edit on files under ~/.claude/projects/*/memory/, plus rm/ls of memory files via Bash", + action: "allow", + handler: async (call) => { + if (call.tool === "Read" || call.tool === "Write" || call.tool === "Edit" || call.tool === "Update") { + const filePath = call.args.file_path; + if (typeof filePath !== "string") return; + return isMemoryPath(filePath) ? true : undefined; + } + + if (call.tool === "Bash") { + const args = await safeBashCommand(call); + if (!args) return; + + if (args[0] === "rm") { + const flags = args.slice(1).filter((t) => t.startsWith("-")); + const paths = args.slice(1).filter((t) => !t.startsWith("-")); + if (paths.length === 0) return; + + if (flags.some((f) => /[rfR]/.test(f))) return; + + const allInMemory = paths.every((p) => { + const resolved = resolve(call.context.cwd, resolveHome(p)); + return isMemoryPath(resolved); + }); + return allInMemory ? true : undefined; + } + + if (args[0] === "ls") { + const paths = args.slice(1).filter((t) => !t.startsWith("-")); + if (paths.length === 0) return; + + const allInMemory = paths.every((p) => { + const resolved = resolve(call.context.cwd, resolveHome(p)); + return isMemoryDirOrChild(resolved); + }); + return allInMemory ? true : undefined; + } + + return; + } + + return; + }, +}; +export default allowMemoryCrud; diff --git a/policies/allow-mkdir-in-project.ts b/policies/allow-mkdir-in-project.ts index a3c03ea..917c2f3 100644 --- a/policies/allow-mkdir-in-project.ts +++ b/policies/allow-mkdir-in-project.ts @@ -1,26 +1,27 @@ import { resolve } from "node:path"; -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; const allowMkdirInProject: Policy = { name: "Allow mkdir in project", description: "Permits mkdir commands when all paths are within the project root", + action: "allow", handler: async (call) => { const args = await safeBashCommand(call); - if (!args || args[0] !== "mkdir") return next(); - if (!call.context.projectRoot) return next(); + if (!args || args[0] !== "mkdir") return; + if (!call.context.projectRoot) return; const root = call.context.projectRoot; const paths = args.slice(1).filter((t) => !t.startsWith("-")); - if (paths.length === 0) return next(); + if (paths.length === 0) return; const allInProject = paths.every((p) => { const resolved = resolve(call.context.cwd, p); return isWithinProject(resolved, call.context); }); - return allInProject ? allow() : next(); + return allInProject ? true : undefined; }, }; export default allowMkdirInProject; diff --git a/policies/allow-non-destructive-git.ts b/policies/allow-non-destructive-git.ts index 506fede..d584313 100644 --- a/policies/allow-non-destructive-git.ts +++ b/policies/allow-non-destructive-git.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommandOrPipeline } from "./parse-bash-ast"; /** @@ -54,20 +54,21 @@ const allowNonDestructiveGit: Policy = { name: "Allow non-destructive git", description: "Auto-approves git commands that don't mutate remote state or discard uncommitted work", + action: "allow", handler: async (call) => { const tokens = await safeBashCommandOrPipeline(call); - if (!tokens) return next(); - if (tokens[0] !== "git") return next(); + if (!tokens) return; + if (tokens[0] !== "git") return; const sub = tokens[1]; - if (!sub) return next(); + if (!sub) return; - if (DESTRUCTIVE_GIT.has(sub)) return next(); + if (DESTRUCTIVE_GIT.has(sub)) return; const rest = tokens.slice(2); - if (hasDestructiveFlags(sub, rest)) return next(); + if (hasDestructiveFlags(sub, rest)) return; - return allow(); + return true; }, }; export default allowNonDestructiveGit; diff --git a/policies/allow-npm-install.ts b/policies/allow-npm-install.ts index 16701c4..ee2c959 100644 --- a/policies/allow-npm-install.ts +++ b/policies/allow-npm-install.ts @@ -1,21 +1,22 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; const allowNpmInstall: Policy = { name: "Allow npm/pnpm/yarn install", description: "Permits npm install, pnpm install, and yarn install commands", + action: "allow", handler: async (call) => { const tokens = await safeBashCommand(call); - if (!tokens) return next(); + if (!tokens) return; const cmd = tokens[0]; const sub = tokens[1]; - if (cmd === "npm" && (sub === "install" || sub === "ci" || sub === "i")) return allow(); - if (cmd === "pnpm" && (sub === "install" || sub === "i")) return allow(); - if (cmd === "yarn" && (sub === "install" || sub === undefined)) return allow(); + if (cmd === "npm" && (sub === "install" || sub === "ci" || sub === "i")) return true; + if (cmd === "pnpm" && (sub === "install" || sub === "i")) return true; + if (cmd === "yarn" && (sub === "install" || sub === undefined)) return true; - return next(); + return; }, }; export default allowNpmInstall; diff --git a/policies/allow-npx-safe.ts b/policies/allow-npx-safe.ts index f9507a4..f46b504 100644 --- a/policies/allow-npx-safe.ts +++ b/policies/allow-npx-safe.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand, safeBashCommandOrPipeline, getAndChainSegments, getArgs, parseShell } from "./parse-bash-ast"; /** Whitelisted npx packages — add entries as needed. */ @@ -15,13 +15,14 @@ function isAllowedNpx(tokens: string[]): boolean { const allowNpxSafe: Policy = { name: "Allow safe npx commands", description: "Permits npx commands for whitelisted packages (playwright, vitest, etc.) and all Playwright MCP tools", + action: "allow", handler: async (call) => { // Allow all Playwright MCP tools - if (call.tool.startsWith("mcp__playwright__")) return allow(); + if (call.tool.startsWith("mcp__playwright__")) return true; // Simple command or pipeline (e.g. npx playwright test 2>&1 | tail -80) const tokens = await safeBashCommandOrPipeline(call); - if (tokens && isAllowedNpx(tokens)) return allow(); + if (tokens && isAllowedNpx(tokens)) return true; // && chain (e.g. cd dir && npx playwright test) if (call.tool === "Bash" && typeof call.args.command === "string") { @@ -30,12 +31,12 @@ const allowNpxSafe: Policy = { const segments = getAndChainSegments(ast); if (segments) { const last = getArgs(segments[segments.length - 1]); - if (last && isAllowedNpx(last)) return allow(); + if (last && isAllowedNpx(last)) return true; } } } - return next(); + return; }, }; export default allowNpxSafe; diff --git a/policies/allow-plan-in-project.ts b/policies/allow-plan-in-project.ts index 2219720..72904b3 100644 --- a/policies/allow-plan-in-project.ts +++ b/policies/allow-plan-in-project.ts @@ -1,4 +1,4 @@ -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; /** * Allow Plan tool calls when the path is within the project root. @@ -7,27 +7,28 @@ import { allow, next, isWithinProject, type Policy } from "../src"; const allowPlanInProject: Policy = { name: "Allow plan in project", description: "Permits Plan tool calls targeting paths within the project root", + action: "allow", handler: async (call) => { if (call.tool !== "Plan") { - return next(); + return; } if (!call.context.projectRoot) { - return next(); + return; } const searchPath = call.args.path; // No path specified — Plan defaults to cwd if (searchPath === undefined) { - return isWithinProject(call.context.cwd, call.context) ? allow() : next(); + return isWithinProject(call.context.cwd, call.context) ? true : undefined; } if (typeof searchPath !== "string") { - return next(); + return; } - return isWithinProject(searchPath, call.context) ? allow() : next(); + return isWithinProject(searchPath, call.context) ? true : undefined; }, }; export default allowPlanInProject; diff --git a/policies/allow-plan-mode.ts b/policies/allow-plan-mode.ts index 688db35..de5ad01 100644 --- a/policies/allow-plan-mode.ts +++ b/policies/allow-plan-mode.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; const PLAN_MODE_TOOLS = new Set(["EnterPlanMode", "ExitPlanMode"]); @@ -9,12 +9,13 @@ const PLAN_MODE_TOOLS = new Set(["EnterPlanMode", "ExitPlanMode"]); const allowPlanMode: Policy = { name: "Allow Plan Mode", description: "Permits EnterPlanMode and ExitPlanMode tool calls", + action: "allow", handler: async (call) => { if (!PLAN_MODE_TOOLS.has(call.tool)) { - return next(); + return; } - return allow(); + return true; }, }; export default allowPlanMode; diff --git a/policies/allow-playwright.ts b/policies/allow-playwright.ts index 3d1862c..0b5a5b1 100644 --- a/policies/allow-playwright.ts +++ b/policies/allow-playwright.ts @@ -1,19 +1,20 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; const allowPlaywright: Policy = { name: "Allow Playwright", description: "Permits npx playwright commands and all Playwright MCP tools", + action: "allow", handler: async (call) => { // Allow all Playwright MCP tools - if (call.tool.startsWith("mcp__playwright__")) return allow(); + if (call.tool.startsWith("mcp__playwright__")) return true; // Allow npx playwright commands const tokens = await safeBashCommand(call); - if (!tokens) return next(); - if (tokens[0] === "npx" && tokens[1] === "playwright") return allow(); + if (!tokens) return; + if (tokens[0] === "npx" && tokens[1] === "playwright") return true; - return next(); + return; }, }; export default allowPlaywright; diff --git a/policies/allow-pure-and-chains.ts b/policies/allow-pure-and-chains.ts index 0bed465..f458657 100644 --- a/policies/allow-pure-and-chains.ts +++ b/policies/allow-pure-and-chains.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, hasUnsafeNodes, @@ -22,25 +22,26 @@ const allowPureAndChains: Policy = { name: "Allow pure command chains", description: "Permits && chains where every segment is a side-effect-free command (php -l, echo, test)", + action: "allow", handler: async (call) => { - if (call.tool !== "Bash") return next(); + if (call.tool !== "Bash") return; const command = call.args?.command; - if (typeof command !== "string") return next(); + if (typeof command !== "string") return; const file = await parseShell(command); - if (!file) return next(); - if (hasUnsafeNodes(file)) return next(); + if (!file) return; + if (hasUnsafeNodes(file)) return; const segments = getAndChainSegments(file); - if (!segments) return next(); + if (!segments) return; for (const segment of segments) { const args = getArgs(segment); - if (!args) return next(); - if (!isPureCommand(args)) return next(); + if (!args) return; + if (!isPureCommand(args)) return; } - return allow(); + return true; }, }; export default allowPureAndChains; diff --git a/policies/allow-read-in-project.ts b/policies/allow-read-in-project.ts index 8d62a86..0fd36a9 100644 --- a/policies/allow-read-in-project.ts +++ b/policies/allow-read-in-project.ts @@ -1,7 +1,7 @@ import { realpathSync } from "fs"; import { homedir } from "os"; import { resolve } from "path"; -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; function resolvePath(p: string, projectRoot: string): string { if (p === "~") return homedir(); @@ -25,17 +25,18 @@ function tryRealpath(p: string): string | null { const allowReadInProject: Policy = { name: "Allow read in project", description: "Permits Read tool calls targeting files within the project root or additional directories", + action: "allow", handler: async (call) => { if (call.tool !== "Read") { - return next(); + return; } if (!call.context.projectRoot) { - return next(); + return; } const filePath = call.args.file_path; - if (typeof filePath !== "string") return next(); + if (typeof filePath !== "string") return; const resolved = resolvePath(filePath, call.context.projectRoot); @@ -50,11 +51,11 @@ const allowReadInProject: Policy = { realContext.additionalDirs = (call.context.additionalDirs ?? []) .map((d) => tryRealpath(d) ?? d); } - return isWithinProject(realTarget, realContext) ? allow() : next(); + return isWithinProject(realTarget, realContext) ? true : undefined; } // File doesn't exist yet — fall back to string prefix check - return isWithinProject(resolved, call.context) ? allow() : next(); + return isWithinProject(resolved, call.context) ? true : undefined; }, }; export default allowReadInProject; diff --git a/policies/allow-read-plugin-cache.ts b/policies/allow-read-plugin-cache.ts index 0d23cff..39a05b6 100644 --- a/policies/allow-read-plugin-cache.ts +++ b/policies/allow-read-plugin-cache.ts @@ -1,6 +1,6 @@ import { join } from "path"; import { homedir } from "os"; -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; function resolveHome(p: string): string { if (p === "~") return homedir(); @@ -17,21 +17,22 @@ const allowReadPluginCache: Policy = { name: "Allow read plugin cache", description: "Permits Read tool calls targeting files within ~/.claude/plugins/cache/", + action: "allow", handler: async (call) => { - if (call.tool !== "Read") return next(); + if (call.tool !== "Read") return; const filePath = call.args.file_path; - if (typeof filePath !== "string") return next(); + if (typeof filePath !== "string") return; const resolved = resolveHome(filePath); if ( resolved === PLUGIN_CACHE || resolved.startsWith(PLUGIN_CACHE + "/") ) { - return allow(); + return true; } - return next(); + return; }, }; export default allowReadPluginCache; diff --git a/policies/allow-read-tool-results.ts b/policies/allow-read-tool-results.ts index 535f6ba..e9694f5 100644 --- a/policies/allow-read-tool-results.ts +++ b/policies/allow-read-tool-results.ts @@ -1,6 +1,6 @@ import { join } from "path"; import { homedir } from "os"; -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; function resolveHome(p: string): string { if (p === "~") return homedir(); @@ -18,20 +18,21 @@ const allowReadToolResults: Policy = { name: "Allow read tool results", description: "Permits Read tool calls targeting files within ~/.claude/projects/*/tool-results/", + action: "allow", handler: async (call) => { - if (call.tool !== "Read") return next(); + if (call.tool !== "Read") return; const filePath = call.args.file_path; - if (typeof filePath !== "string") return next(); + if (typeof filePath !== "string") return; const resolved = resolveHome(filePath); - if (!resolved.startsWith(PROJECTS_DIR + "/")) return next(); + if (!resolved.startsWith(PROJECTS_DIR + "/")) return; // Only allow reading within tool-results/ subdirectories const relative = resolved.slice(PROJECTS_DIR.length + 1); - if (/^[^/]+\/[^/]+\/tool-results\//.test(relative)) return allow(); + if (/^[^/]+\/[^/]+\/tool-results\//.test(relative)) return true; - return next(); + return; }, }; export default allowReadToolResults; diff --git a/policies/allow-rm-project-tmp.ts b/policies/allow-rm-project-tmp.ts index 5e8d11c..52bf5f0 100644 --- a/policies/allow-rm-project-tmp.ts +++ b/policies/allow-rm-project-tmp.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; /** @@ -11,28 +11,29 @@ const allowRmProjectTmp: Policy = { name: "Allow rm in project tmp/", description: "Permits rm commands when all targets are within the project's tmp/ directory", + action: "allow", handler: async (call) => { const args = await safeBashCommand(call); - if (!args || args[0] !== "rm") return next(); - if (!call.context.projectRoot) return next(); + if (!args || args[0] !== "rm") return; + if (!call.context.projectRoot) return; const tmpDirs = [call.context.projectRoot, ...(call.context.additionalDirs ?? [])] .map((d) => resolve(d, "tmp")); const flags = args.slice(1).filter((t) => t.startsWith("-")); const paths = args.slice(1).filter((t) => !t.startsWith("-")); - if (paths.length === 0) return next(); + if (paths.length === 0) return; // Require approval for -r or -f flags const hasUnsafeFlag = flags.some((f) => /[rf]/.test(f)); - if (hasUnsafeFlag) return next(); + if (hasUnsafeFlag) return; const allInTmp = paths.every((p) => { const resolved = resolve(call.context.cwd, p); return tmpDirs.some((tmp) => resolved.startsWith(tmp + "/")); }); - return allInTmp ? allow() : next(); + return allInTmp ? true : undefined; }, }; export default allowRmProjectTmp; diff --git a/policies/allow-rmdir-project-tmp.ts b/policies/allow-rmdir-project-tmp.ts new file mode 100644 index 0000000..331170f --- /dev/null +++ b/policies/allow-rmdir-project-tmp.ts @@ -0,0 +1,34 @@ +import { resolve } from "node:path"; +import type { Policy } from "../src"; +import { safeBashCommand } from "./parse-bash-ast"; + +/** + * Allow rmdir for the project's tmp/ directory and empty subdirectories within it. + * rmdir refuses to remove non-empty directories regardless of flags, so this + * cannot delete data — only clean up empty scaffolding. + */ +const allowRmdirProjectTmp: Policy = { + name: "Allow rmdir in project tmp/", + description: + "Permits rmdir for the project's tmp/ directory or empty subdirectories within it", + action: "allow", + handler: async (call) => { + const args = await safeBashCommand(call); + if (!args || args[0] !== "rmdir") return; + if (!call.context.projectRoot) return; + + const tmpDirs = [call.context.projectRoot, ...(call.context.additionalDirs ?? [])] + .map((d) => resolve(d, "tmp")); + const paths = args.slice(1).filter((t) => !t.startsWith("-")); + + if (paths.length === 0) return; + + const allInTmp = paths.every((p) => { + const resolved = resolve(call.context.cwd, p); + return tmpDirs.some((tmp) => resolved === tmp || resolved.startsWith(tmp + "/")); + }); + + return allInTmp ? true : undefined; + }, +}; +export default allowRmdirProjectTmp; diff --git a/policies/allow-safe-read-commands.ts b/policies/allow-safe-read-commands.ts index eacee18..382a5dd 100644 --- a/policies/allow-safe-read-commands.ts +++ b/policies/allow-safe-read-commands.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; -import { allow, next, isWithinProject, type Policy } from "../src"; -import { parseShell, getPipelineCommands, getArgs, isSafeFilter } from "./parse-bash-ast"; +import { isWithinProject, type Policy } from "../src"; +import { parseShell, getPipelineCommands, getArgs, isSafeFilter, wordToString, Op } from "./parse-bash-ast"; +import type { Stmt } from "./parse-bash-ast"; /** * Read-only commands safe to run standalone on files within the project. @@ -19,6 +20,7 @@ const SAFE_READ_COMMANDS = new Set([ "du", "diff", "jq", + "fx", "sed", ]); @@ -91,6 +93,23 @@ function getJqFilePaths(tokens: string[]): string[] { return paths; } +/** + * Extract file paths from `<` stdin-redirect targets on the first pipeline segment. + * Other redirect ops (`>`, `>>`, etc.) are ignored here — write redirects are handled + * by the separate deny-writes-outside-project policy. This function exists so the + * in-project containment check covers `cmd < file` reads as well as positional args. + */ +function getStdinRedirectPaths(stmt: Stmt): string[] { + const paths: string[] = []; + if (!stmt.Redirs) return paths; + for (const r of stmt.Redirs) { + if (r.Op !== Op.RdrIn) continue; + const target = wordToString(r.Word); + if (target) paths.push(target); + } + return paths; +} + function isInProject(path: string, cwd: string, context: { projectRoot: string; additionalDirs: string[] }): boolean { const resolved = resolve(cwd, path); return isWithinProject(resolved, context); @@ -99,49 +118,54 @@ function isInProject(path: string, cwd: string, context: { projectRoot: string; const allowSafeReadCommands: Policy = { name: "Allow safe read commands in project", description: "Permits read-only commands (cat, head, tail, wc, etc.) when all file paths are within the project root", + action: "allow", handler: async (call) => { - if (call.tool !== "Bash") return next(); - if (typeof call.args.command !== "string") return next(); - if (!call.context.projectRoot) return next(); + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; + if (!call.context.projectRoot) return; const ast = await parseShell(call.args.command); - if (!ast || ast.Stmts.length !== 1) return next(); + if (!ast || ast.Stmts.length !== 1) return; const cmds = getPipelineCommands(ast.Stmts[0]); - if (!cmds || cmds.length === 0) return next(); + if (!cmds || cmds.length === 0) return; const tokens = getArgs(cmds[0]); - if (!tokens || !SAFE_READ_COMMANDS.has(tokens[0])) return next(); + if (!tokens || !SAFE_READ_COMMANDS.has(tokens[0])) return; // sed: reject in-place editing if (tokens[0] === "sed") { for (const t of tokens) { if (t === "-i" || t.startsWith("-i") || t === "--in-place" || t.startsWith("--in-place=")) - return next(); + return; } } // All subsequent pipeline segments must be safe filters for (let i = 1; i < cmds.length; i++) { const segArgs = getArgs(cmds[i]); - if (!segArgs || !isSafeFilter(segArgs)) return next(); + if (!segArgs || !isSafeFilter(segArgs)) return; } - const paths = tokens[0] === "sed" + const positionalPaths = tokens[0] === "sed" ? getSedFilePaths(tokens) : tokens[0] === "jq" ? getJqFilePaths(tokens) : tokens.slice(1).filter((t) => !t.startsWith("-")); + // Also gate on `<` redirect targets so `cmd < file` is constrained the same as `cmd file`. + const redirectPaths = getStdinRedirectPaths(ast.Stmts[0]); + const paths = [...positionalPaths, ...redirectPaths]; + // No file args — allowed only if cwd is in project if (paths.length === 0) { return isInProject(call.context.cwd, call.context.cwd, call.context) - ? allow() - : next(); + ? true + : undefined; } const allInProject = paths.every((p) => isInProject(p, call.context.cwd, call.context)); - return allInProject ? allow() : next(); + return allInProject ? true : undefined; }, }; export default allowSafeReadCommands; diff --git a/policies/allow-search-in-project.ts b/policies/allow-search-in-project.ts index 46ff3e2..208e9ad 100644 --- a/policies/allow-search-in-project.ts +++ b/policies/allow-search-in-project.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import { allow, next, isWithinProject, type Policy } from "../src"; +import { isWithinProject, type Policy } from "../src"; /** * Allow Search/Glob tool calls when the search path is within the project root. @@ -8,29 +8,30 @@ import { allow, next, isWithinProject, type Policy } from "../src"; const allowSearchInProject: Policy = { name: "Allow search in project", description: "Permits Search and Glob tool calls targeting paths within the project root", + action: "allow", handler: async (call) => { if (call.tool !== "Search" && call.tool !== "Glob") { - return next(); + return; } if (!call.context.projectRoot) { - return next(); + return; } const searchPath = call.args.path; // No path specified — Search defaults to cwd if (searchPath === undefined) { - return isWithinProject(call.context.cwd, call.context) ? allow() : next(); + return isWithinProject(call.context.cwd, call.context) ? true : undefined; } if (typeof searchPath !== "string") { - return next(); + return; } // Resolve relative paths against cwd const resolved = resolve(call.context.cwd, searchPath); - return isWithinProject(resolved, call.context) ? allow() : next(); + return isWithinProject(resolved, call.context) ? true : undefined; }, }; export default allowSearchInProject; diff --git a/policies/allow-skill-crud.ts b/policies/allow-skill-crud.ts new file mode 100644 index 0000000..dde0410 --- /dev/null +++ b/policies/allow-skill-crud.ts @@ -0,0 +1,61 @@ +import { join } from "path"; +import { homedir } from "os"; +import type { Policy } from "../src"; + +function resolveHome(p: string): string { + if (p === "~") return homedir(); + if (p.startsWith("~/")) return homedir() + p.slice(1); + return p; +} + +const USER_SKILLS = join(homedir(), ".claude", "skills"); + +function isSkillPath(filePath: string, projectRoot?: string): boolean { + const resolved = resolveHome(filePath); + + // User-level: ~/.claude/skills/ + if (resolved === USER_SKILLS || resolved.startsWith(USER_SKILLS + "/")) { + return true; + } + + // Project-level: /.claude/skills/ + if (projectRoot) { + const projectSkills = join(projectRoot, ".claude", "skills"); + if ( + resolved === projectSkills || + resolved.startsWith(projectSkills + "/") + ) { + return true; + } + } + + return false; +} + +/** + * Allow Read, Write, Edit, and Glob on Claude skill files in + * ~/.claude/skills/ and /.claude/skills/. + */ +const allowSkillCrud: Policy = { + name: "Allow skill CRUD", + description: + "Permits Read, Write, Edit, and Glob targeting Claude skill files in ~/.claude/skills/ or .claude/skills/", + action: "allow", + handler: async (call) => { + if (call.tool === "Read" || call.tool === "Write" || call.tool === "Edit") { + const filePath = call.args.file_path; + if (typeof filePath !== "string") return; + if (isSkillPath(filePath, call.context.projectRoot)) return true; + } + + if (call.tool === "Glob") { + const path = call.args.path; + if (typeof path === "string" && isSkillPath(path, call.context.projectRoot)) { + return true; + } + } + + return; + }, +}; +export default allowSkillCrud; diff --git a/policies/allow-sleep.ts b/policies/allow-sleep.ts index e97f61e..4914bbb 100644 --- a/policies/allow-sleep.ts +++ b/policies/allow-sleep.ts @@ -1,16 +1,17 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { safeBashCommand } from "./parse-bash-ast"; const allowSleep: Policy = { name: "Allow sleep", description: "Permits sleep commands with a numeric duration argument", + action: "allow", handler: async (call) => { const tokens = await safeBashCommand(call); - if (!tokens) return next(); - if (tokens[0] !== "sleep" || tokens.length !== 2) return next(); + if (!tokens) return; + if (tokens[0] !== "sleep" || tokens.length !== 2) return; // Only allow numeric durations (e.g. 5, 0.5, 1s, 2m, 3h) - if (/^\d+(\.\d+)?[smhd]?$/.test(tokens[1])) return allow(); - return next(); + if (/^\d+(\.\d+)?[smhd]?$/.test(tokens[1])) return true; + return; }, }; export default allowSleep; diff --git a/policies/allow-superpowers-skills.ts b/policies/allow-superpowers-skills.ts index 4ff8ba8..dc1ae94 100644 --- a/policies/allow-superpowers-skills.ts +++ b/policies/allow-superpowers-skills.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; function isSuperpowers(value: unknown): boolean { return ( @@ -11,16 +11,17 @@ const allowSuperpowersSkills: Policy = { name: "Allow superpowers skills", description: "Permits Skill and Agent tool calls for any superpowers:* skill or subagent type", + action: "allow", handler: async (call) => { if (call.tool === "Skill" && isSuperpowers(call.args.skill)) { - return allow(); + return true; } if (call.tool === "Agent" && isSuperpowers(call.args.subagent_type)) { - return allow(); + return true; } - return next(); + return; }, }; diff --git a/policies/allow-task-create.ts b/policies/allow-task-create.ts index 88df728..0a577de 100644 --- a/policies/allow-task-create.ts +++ b/policies/allow-task-create.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow TaskCreate tool calls unconditionally. @@ -6,12 +6,13 @@ import { allow, next, type Policy } from "../src"; const allowTaskCreate: Policy = { name: "Allow TaskCreate", description: "Permits TaskCreate tool calls for task tracking", + action: "allow", handler: async (call) => { if (call.tool !== "TaskCreate") { - return next(); + return; } - return allow(); + return true; }, }; export default allowTaskCreate; diff --git a/policies/allow-task-crud.ts b/policies/allow-task-crud.ts index 5906a5d..a02ace9 100644 --- a/policies/allow-task-crud.ts +++ b/policies/allow-task-crud.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; const TASK_TOOLS = new Set([ "TaskCreate", @@ -17,12 +17,13 @@ const allowTaskCrud: Policy = { name: "Allow Task CRUD", description: "Permits TaskCreate, TaskUpdate, TaskGet, TaskList, TaskOutput, and TaskStop tool calls", + action: "allow", handler: async (call) => { if (!TASK_TOOLS.has(call.tool)) { - return next(); + return; } - return allow(); + return true; }, }; export default allowTaskCrud; diff --git a/policies/allow-tool-search.ts b/policies/allow-tool-search.ts index 7df85d8..10f7c0c 100644 --- a/policies/allow-tool-search.ts +++ b/policies/allow-tool-search.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow all ToolSearch tool calls unconditionally. @@ -7,9 +7,10 @@ import { allow, next, type Policy } from "../src"; const allowToolSearch: Policy = { name: "Allow ToolSearch", description: "Permits all ToolSearch tool calls", + action: "allow", handler: async (call) => { - if (call.tool !== "ToolSearch") return next(); - return allow(); + if (call.tool !== "ToolSearch") return; + return true; }, }; export default allowToolSearch; diff --git a/policies/allow-toolgate-test.ts b/policies/allow-toolgate-test.ts new file mode 100644 index 0000000..dae97b6 --- /dev/null +++ b/policies/allow-toolgate-test.ts @@ -0,0 +1,20 @@ +import type { Policy } from "../src"; +import { safeBashCommand } from "./parse-bash-ast"; + +/** + * Allow `toolgate test ...`. The `test` subcommand is a dry-run that parses + * a tool call and reports which policy would fire — it never executes the + * underlying tool, so any argument shape is safe. + */ +const allowToolgateTest: Policy = { + name: "Allow toolgate test", + description: "Permits toolgate test (dry-run policy check) with any arguments", + action: "allow", + handler: async (call) => { + const tokens = await safeBashCommand(call); + if (!tokens) return; + if (tokens[0] !== "toolgate" || tokens[1] !== "test") return; + return true; + }, +}; +export default allowToolgateTest; diff --git a/policies/allow-version-probes.ts b/policies/allow-version-probes.ts new file mode 100644 index 0000000..384f779 --- /dev/null +++ b/policies/allow-version-probes.ts @@ -0,0 +1,19 @@ +import type { Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; + +const VERSION_FLAGS = new Set(["--version", "-version", "--help"]); + +const allowVersionProbes: Policy = { + name: "Allow version probes", + description: + "Permits ` --version` / ` -version` / ` --help` invocations (optionally piped through safe filters); rejects any additional positional args", + action: "allow", + handler: async (call) => { + const tokens = await safeBashCommandOrPipeline(call); + if (!tokens) return; + if (tokens.length !== 2) return; + if (!VERSION_FLAGS.has(tokens[1])) return; + return true; + }, +}; +export default allowVersionProbes; diff --git a/policies/allow-web-fetch.ts b/policies/allow-web-fetch.ts index 75318df..728e10e 100644 --- a/policies/allow-web-fetch.ts +++ b/policies/allow-web-fetch.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow all WebFetch tool calls unconditionally. @@ -7,9 +7,10 @@ import { allow, next, type Policy } from "../src"; const allowWebFetch: Policy = { name: "Allow WebFetch", description: "Permits all WebFetch tool calls", + action: "allow", handler: async (call) => { - if (call.tool !== "WebFetch") return next(); - return allow(); + if (call.tool !== "WebFetch") return; + return true; }, }; export default allowWebFetch; diff --git a/policies/allow-web-search.ts b/policies/allow-web-search.ts index 7dbb67a..995afa9 100644 --- a/policies/allow-web-search.ts +++ b/policies/allow-web-search.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow all WebSearch tool calls unconditionally. @@ -7,9 +7,10 @@ import { allow, next, type Policy } from "../src"; const allowWebSearch: Policy = { name: "Allow WebSearch", description: "Permits all WebSearch tool calls", + action: "allow", handler: async (call) => { - if (call.tool !== "WebSearch") return next(); - return allow(); + if (call.tool !== "WebSearch") return; + return true; }, }; export default allowWebSearch; diff --git a/policies/allow-webfetch-claude.ts b/policies/allow-webfetch-claude.ts index 51fffd2..d8d7471 100644 --- a/policies/allow-webfetch-claude.ts +++ b/policies/allow-webfetch-claude.ts @@ -1,4 +1,4 @@ -import { allow, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Allow WebFetch requests to *.claude.com URLs. @@ -6,26 +6,27 @@ import { allow, next, type Policy } from "../src"; const allowWebFetchClaude: Policy = { name: "Allow WebFetch claude.com", description: "Permits WebFetch requests to claude.com and subdomains", + action: "allow", handler: async (call) => { if (call.tool !== "WebFetch") { - return next(); + return; } const url = call.args.url; if (typeof url !== "string") { - return next(); + return; } try { const parsed = new URL(url); if (parsed.hostname === "claude.com" || parsed.hostname.endsWith(".claude.com")) { - return allow(); + return true; } } catch { // invalid URL, pass through } - return next(); + return; }, }; export default allowWebFetchClaude; diff --git a/policies/allow-which.ts b/policies/allow-which.ts new file mode 100644 index 0000000..7d6274f --- /dev/null +++ b/policies/allow-which.ts @@ -0,0 +1,15 @@ +import type { Policy } from "../src"; +import { safeBashCommandOrPipeline } from "./parse-bash-ast"; + +const allowWhich: Policy = { + name: "Allow which", + description: "Permits which for resolving command paths from $PATH; optionally piped through safe filters", + action: "allow", + handler: async (call) => { + const tokens = await safeBashCommandOrPipeline(call); + if (!tokens) return; + if (tokens[0] !== "which") return; + return true; + }, +}; +export default allowWhich; diff --git a/policies/deny-and-chains.ts b/policies/deny-and-chains.ts new file mode 100644 index 0000000..36ec2db --- /dev/null +++ b/policies/deny-and-chains.ts @@ -0,0 +1,108 @@ +import type { Policy } from "../src"; +import { + parseShell, + Op, + type Stmt, + type BinaryCmd, + type CallExpr, + type Lit, + type SglQuoted, + type DblQuoted, +} from "./parse-bash-ast"; + +const ENV_SETTERS = new Set(["eval", "source", ".", "export"]); + +const STEERING_MESSAGE = `Each side of \`&&\` becomes its own Bash call. Toolgate evaluates each independently — per-call audit entry, per-call permission cache, per-call policy specificity. + +Bad: mkdir -p tmp && grep ... > tmp/file && wc -l tmp/file +Good: mkdir -p tmp + grep ... > tmp/file + wc -l tmp/file + +The shell's working directory persists across separate Bash calls in this session, so \`cd \` in one call carries into the next. + +Exempt: chains whose leaves include \`eval\`, \`source\`, \`.\` (dot), or \`export\` — env-setters whose effects must persist into the next leaf and can't survive call decomposition.`; + +function collectAndLeaves(bin: BinaryCmd, out: Stmt[]): void { + const visit = (sideStmt: Stmt) => { + const c = sideStmt.Cmd; + if (c && c.Type === "BinaryCmd" && (c as BinaryCmd).Op === Op.And) { + collectAndLeaves(c as BinaryCmd, out); + } else { + out.push(sideStmt); + } + }; + visit(bin.X); + visit(bin.Y); +} + +/** + * Permissive extractor for a leaf's first-word command name. Unlike `getArgs`, + * this does NOT bail on command-substitution, parameter expansion, or + * assignments anywhere in the arg list — we only care about the first + * positional token (the command name). `eval "$(fnm env)"` should still + * resolve to "eval" even though the second arg contains a CmdSubst. + */ +function firstArg0OfLeaf(leaf: Stmt): string | null { + const cmd = leaf.Cmd; + if (!cmd) return null; + if (cmd.Type === "CallExpr") { + const args = (cmd as CallExpr).Args; + if (!args || args.length === 0) return null; + const parts = args[0].Parts; + if (!parts || parts.length === 0) return null; + const p = parts[0]; + if (p.Type === "Lit") return (p as Lit).Value; + if (p.Type === "SglQuoted") return (p as SglQuoted).Value; + if (p.Type === "DblQuoted") { + const d = p as DblQuoted; + if (d.Parts?.length === 1 && d.Parts[0].Type === "Lit") { + return (d.Parts[0] as Lit).Value; + } + } + return null; + } + // DeclClause covers `export`, `declare`, `local`, `readonly`, `typeset`. + if (cmd.Type === "DeclClause") { + return (cmd as any).Variant?.Value ?? null; + } + if (cmd.Type === "BinaryCmd") { + const bin = cmd as BinaryCmd; + if (bin.Op === Op.Pipe || bin.Op === Op.PipeAll) { + return firstArg0OfLeaf(bin.X); + } + } + return null; +} + +const denyAndChains: Policy = { + name: "Deny && chains", + description: + "Denies ` && ` chains so each step is evaluated atomically. Exempts chains whose leaves include env-setters (eval/source/./export) whose effects must persist into subsequent leaves.", + action: "deny", + handler: async (call) => { + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; + + const ast = await parseShell(call.args.command); + if (!ast || ast.Stmts.length !== 1) return; + + const stmt = ast.Stmts[0]; + const cmd = stmt.Cmd; + if (!cmd || cmd.Type !== "BinaryCmd") return; + const bin = cmd as BinaryCmd; + if (bin.Op !== Op.And) return; + + const leaves: Stmt[] = []; + collectAndLeaves(bin, leaves); + if (leaves.length < 2) return; + + for (const leaf of leaves) { + const first = firstArg0OfLeaf(leaf); + if (first && ENV_SETTERS.has(first)) return; + } + + return STEERING_MESSAGE; + }, +}; +export default denyAndChains; diff --git a/policies/deny-bash-grep.ts b/policies/deny-bash-grep.ts index aad4e7c..ce5bfe0 100644 --- a/policies/deny-bash-grep.ts +++ b/policies/deny-bash-grep.ts @@ -1,30 +1,27 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, getPipelineCommands, getArgs } from "./parse-bash-ast"; const denyBashGrep: Policy = { name: "Deny bash grep", description: "Rejects grep/egrep/fgrep/rg in Bash — use the built-in Grep tool instead", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); - if (typeof call.args.command !== "string") return next(); + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; const ast = await parseShell(call.args.command); - if (!ast || ast.Stmts.length !== 1) return next(); + if (!ast || ast.Stmts.length !== 1) return; const cmds = getPipelineCommands(ast.Stmts[0]); - if (!cmds) return next(); + if (!cmds) return; const args = getArgs(cmds[0]); - if (!args) return next(); + if (!args) return; const cmd = args[0]; if (cmd === "grep" || cmd === "egrep" || cmd === "fgrep" || cmd === "rg") { - return deny( - "Do not use `grep` or `rg` in Bash. Use the built-in Grep tool instead — it supports regex, glob filters, and output modes (content, files_with_matches, count).", - ); + return "Do not use `grep` or `rg` in Bash. Use the built-in Grep tool instead — it supports regex, glob filters, and output modes (content, files_with_matches, count)."; } - - return next(); }, }; export default denyBashGrep; diff --git a/policies/deny-cd-chained.ts b/policies/deny-cd-chained.ts index e007851..e27064e 100644 --- a/policies/deny-cd-chained.ts +++ b/policies/deny-cd-chained.ts @@ -1,4 +1,4 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, getArgs, Op } from "./parse-bash-ast"; import type { BinaryCmd, Stmt } from "./parse-bash-ast"; @@ -23,19 +23,18 @@ const denyCdChained: Policy = { name: "Deny cd chained with other commands", description: "Rejects cd && ... or cd; ... chains — run cd separately, the working directory persists between calls", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); + if (call.tool !== "Bash") return; const cmd = call.args.command; - if (typeof cmd !== "string") return next(); + if (typeof cmd !== "string") return; const ast = await parseShell(cmd); - if (!ast) return next(); + if (!ast) return; // Multiple statements (cd ...; other ...) — check if first is cd if (ast.Stmts.length > 1 && firstCommandIs(ast.Stmts[0], "cd")) { - return deny( - "Don't chain commands after `cd`. Run `cd ` as its own Bash call — the working directory persists between calls.", - ); + return "Don't chain commands after `cd`. Run `cd ` as its own Bash call — the working directory persists between calls."; } // Single statement that's a BinaryCmd (cd ... && other ...) @@ -45,13 +44,9 @@ const denyCdChained: Policy = { (bin.Op === Op.And || bin.Op === Op.Or) && firstCommandIs(bin.X, "cd") ) { - return deny( - "Don't chain commands after `cd`. Run `cd ` as its own Bash call — the working directory persists between calls.", - ); + return "Don't chain commands after `cd`. Run `cd ` as its own Bash call — the working directory persists between calls."; } } - - return next(); }, }; export default denyCdChained; diff --git a/policies/deny-gh-heredoc.ts b/policies/deny-gh-heredoc.ts index b33045e..9d32d6e 100644 --- a/policies/deny-gh-heredoc.ts +++ b/policies/deny-gh-heredoc.ts @@ -1,4 +1,4 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, hasUnsafeNodes, Op } from "./parse-bash-ast"; import type { ShellFile } from "./parse-bash-ast"; @@ -25,37 +25,38 @@ const denyGhHeredoc: Policy = { name: "Deny gh/git heredoc/command substitution", description: "Rejects gh/git commands containing $(…) or heredoc redirects — use Write tool + --body-file, --input, or -F instead", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); + if (call.tool !== "Bash") return; const command = call.args.command; - if (typeof command !== "string") return next(); + if (typeof command !== "string") return; // Quick check: must involve gh or git - if (!command.includes("gh ") && !command.includes("git ")) return next(); + if (!command.includes("gh ") && !command.includes("git ")) return; const ast = await parseShell(command); - if (!ast) return next(); + if (!ast) return; // Deny if the AST contains unsafe nodes (CmdSubst, ParamExp, etc.) // or heredoc redirects (<< / <<-) - if (!hasUnsafeNodes(ast) && !hasHeredocRedirects(ast)) return next(); + if (!hasUnsafeNodes(ast) && !hasHeredocRedirects(ast)) return; if (command.includes("git ")) { - return deny( + return ( "Do not use command substitution or heredocs in git commands. " + - "Instead, write the message to a file in the project's tmp/ directory with the Write tool, " + - "then use `git commit -F tmp/` or `git tag -F tmp/`. " + - "Clean up with `rm tmp/` afterwards. " + - "This avoids shell escaping issues and lets the user review the content first.", + "Instead, write the message to a file in the project's tmp/ directory with the Write tool, " + + "then use `git commit -F tmp/` or `git tag -F tmp/`. " + + "Clean up with `rm tmp/` afterwards. " + + "This avoids shell escaping issues and lets the user review the content first." ); } - return deny( + return ( "Do not use command substitution or heredocs in gh commands. " + - "Instead, write the body to a file in the project's tmp/ directory with the Write tool, " + - "then use `gh pr comment --body-file tmp/`, `gh issue comment --body-file tmp/`, " + - "or `gh api --input tmp/`. Clean up with `rm tmp/` afterwards. " + - "This avoids shell escaping issues and lets the user review the content first.", + "Instead, write the body to a file in the project's tmp/ directory with the Write tool, " + + "then use `gh pr comment --body-file tmp/`, `gh issue comment --body-file tmp/`, " + + "or `gh api --input tmp/`. Clean up with `rm tmp/` afterwards. " + + "This avoids shell escaping issues and lets the user review the content first." ); }, }; diff --git a/policies/deny-git-add-and-commit.ts b/policies/deny-git-add-and-commit.ts index 9adaa12..2c32a4f 100644 --- a/policies/deny-git-add-and-commit.ts +++ b/policies/deny-git-add-and-commit.ts @@ -1,22 +1,21 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, findGitSubcommands } from "./parse-bash-ast"; const denyGitAddAndCommit: Policy = { name: "Deny git add-and-commit", description: "Blocks compound git add+commit commands, forcing separate steps", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); - if (typeof call.args.command !== "string") return next(); + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; const ast = await parseShell(call.args.command); - if (!ast) return next(); + if (!ast) return; const subcommands = findGitSubcommands(ast); if (subcommands.includes("add") && subcommands.includes("commit")) { - return deny("Split git add and git commit into separate steps"); + return "Split git add and git commit into separate steps"; } - - return next(); }, }; export default denyGitAddAndCommit; diff --git a/policies/deny-git-chained.ts b/policies/deny-git-chained.ts index c0d06d7..eda9c4c 100644 --- a/policies/deny-git-chained.ts +++ b/policies/deny-git-chained.ts @@ -1,4 +1,4 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, Op } from "./parse-bash-ast"; /** @@ -11,14 +11,15 @@ const denyGitChained: Policy = { name: "Deny git chained with other commands", description: "Rejects git && ..., git; ..., or git || ... chains — run each git command separately", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); + if (call.tool !== "Bash") return; const cmd = call.args.command; - if (typeof cmd !== "string") return next(); - if (!cmd.includes("git")) return next(); + if (typeof cmd !== "string") return; + if (!cmd.includes("git")) return; const ast = await parseShell(cmd); - if (!ast) return next(); + if (!ast) return; // Multiple statements means ; chaining if (ast.Stmts.length > 1) { @@ -28,22 +29,16 @@ const denyGitChained: Policy = { return firstArg?.Type === "Lit" && firstArg.Value === "git"; }); if (hasGit) { - return deny( - "Don't chain git commands. Run each git command as its own Bash call for independent policy evaluation and error handling.", - ); + return "Don't chain git commands. Run each git command as its own Bash call for independent policy evaluation and error handling."; } } // Single statement with BinaryCmd (&&, ||) if (ast.Stmts.length === 1 && ast.Stmts[0].Cmd?.Type === "BinaryCmd") { if (containsGitBinary(ast.Stmts[0].Cmd)) { - return deny( - "Don't chain git commands. Run each git command as its own Bash call for independent policy evaluation and error handling.", - ); + return "Don't chain git commands. Run each git command as its own Bash call for independent policy evaluation and error handling."; } } - - return next(); }, }; diff --git a/policies/deny-git-dash-c.ts b/policies/deny-git-dash-c.ts index b3e5927..819ab48 100644 --- a/policies/deny-git-dash-c.ts +++ b/policies/deny-git-dash-c.ts @@ -1,4 +1,4 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; /** * Deny `git -C ` commands. Claude should use the current working @@ -8,18 +8,15 @@ const denyGitDashC: Policy = { name: "Deny git -C", description: "Rejects git commands using -C flag — use the current working directory instead", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); + if (call.tool !== "Bash") return; const cmd = call.args.command; - if (typeof cmd !== "string") return next(); + if (typeof cmd !== "string") return; if (/\bgit\s+-C\b/.test(cmd)) { - return deny( - "Do not use `git -C `. Just run the git command normally — it will use the current working directory.", - ); + return "Do not use `git -C `. Just run the git command normally — it will use the current working directory."; } - - return next(); }, }; export default denyGitDashC; diff --git a/policies/deny-mixed-pure-chains.ts b/policies/deny-mixed-pure-chains.ts index 69667f3..6a71886 100644 --- a/policies/deny-mixed-pure-chains.ts +++ b/policies/deny-mixed-pure-chains.ts @@ -1,4 +1,4 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, getAllLeafCommands, @@ -10,16 +10,17 @@ const denyMixedPureChains: Policy = { name: "Deny mixed pure chains", description: "Blocks compound commands mixing pure (sleep, echo) and non-pure commands, forcing separate steps", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); + if (call.tool !== "Bash") return; const command = call.args?.command; - if (typeof command !== "string") return next(); + if (typeof command !== "string") return; const ast = await parseShell(command); - if (!ast) return next(); + if (!ast) return; const leaves = getAllLeafCommands(ast); - if (!leaves || leaves.length < 2) return next(); + if (!leaves || leaves.length < 2) return; let hasPure = false; let hasNonPure = false; @@ -32,13 +33,9 @@ const denyMixedPureChains: Policy = { hasNonPure = true; } if (hasPure && hasNonPure) { - return deny( - "Split pure commands (sleep, echo, etc.) from other commands so each can be evaluated independently", - ); + return "Split pure commands (sleep, echo, etc.) from other commands so each can be evaluated independently"; } } - - return next(); }, }; export default denyMixedPureChains; diff --git a/policies/deny-perl-one-liners.ts b/policies/deny-perl-one-liners.ts new file mode 100644 index 0000000..8bf42ff --- /dev/null +++ b/policies/deny-perl-one-liners.ts @@ -0,0 +1,53 @@ +import type { Policy } from "../src"; +import { parseShell, getAllLeafCommands, getArgs } from "./parse-bash-ast"; + +const STEERING_MESSAGE = `Perl one-liners (perl -e/-ne/-pe/-E/-ple etc.) execute arbitrary code and can't be auto-allowed. For text wrangling, use these safe-by-design tools (auto-allowed as mid-pipeline filters): + + Multi-capture regex extraction: rg -oP '' --replace '$1|$2|$3' + Range between markers: sed -n '/START/,/END/p' + Column selection: cut -d: -f1 or choose 0 2 + Find/replace on stdin: sd '' '' + HTML query (CSS selectors): htmlq '' + XML query (XPath): xq -x '' + JSON query: jq '' (or fx, gron) + +If perl is genuinely required, save the script to scripts/.pl and call it as ./scripts/.pl — that file is then git-auditable.`; + +/** + * Detect perl flags that introduce an inline script: + * -e execute + * -E execute with extended features + * -ne / -pe -e combined with -n / -p (implicit loop) + * -ane / -nle / -ple / -pae etc. + * + * Matches: lowercase short-flag combos containing 'e', OR exactly -E. + * Skips: -Mmodule, -I/path, -D, --version, etc. + */ +const PERL_INLINE_FLAG = /^(-[a-z]*e[a-z]*|-E)$/; + +const denyPerlOneLiners: Policy = { + name: "Deny perl one-liners with steering", + description: + "Denies inline perl scripts (perl -e/-ne/-pe/etc.) and points Claude at safer alternatives (rg --replace, sd, choose, sed, htmlq, xq)", + action: "deny", + handler: async (call) => { + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; + + const ast = await parseShell(call.args.command); + if (!ast) return; + + const leaves = getAllLeafCommands(ast); + if (!leaves) return; + + for (const stmt of leaves) { + const args = getArgs(stmt); + if (!args || args.length === 0) continue; + if (args[0] !== "perl") continue; + for (const t of args.slice(1)) { + if (PERL_INLINE_FLAG.test(t)) return STEERING_MESSAGE; + } + } + }, +}; +export default denyPerlOneLiners; diff --git a/policies/deny-ssh-compound.ts b/policies/deny-ssh-compound.ts index af0340e..2176144 100644 --- a/policies/deny-ssh-compound.ts +++ b/policies/deny-ssh-compound.ts @@ -1,4 +1,4 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, Op } from "./parse-bash-ast"; /** @@ -10,35 +10,30 @@ const denySshCompound: Policy = { name: "Deny ssh in compound commands", description: "Rejects compound Bash commands (&&, ||, ;) that contain ssh — run ssh separately for explicit approval", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); + if (call.tool !== "Bash") return; const cmd = call.args.command; - if (typeof cmd !== "string") return next(); - if (!cmd.includes("ssh")) return next(); + if (typeof cmd !== "string") return; + if (!cmd.includes("ssh")) return; const ast = await parseShell(cmd); - if (!ast) return next(); + if (!ast) return; // Multiple statements means ; chaining if (ast.Stmts.length > 1) { const hasSsh = ast.Stmts.some((stmt) => containsSsh(stmt.Cmd)); if (hasSsh) { - return deny( - "Don't chain commands with ssh. Run ssh as its own Bash call for explicit approval.", - ); + return "Don't chain commands with ssh. Run ssh as its own Bash call for explicit approval."; } } // Single statement with BinaryCmd (&&, ||) if (ast.Stmts.length === 1 && ast.Stmts[0].Cmd?.Type === "BinaryCmd") { if (containsSshBinary(ast.Stmts[0].Cmd)) { - return deny( - "Don't chain commands with ssh. Run ssh as its own Bash call for explicit approval.", - ); + return "Don't chain commands with ssh. Run ssh as its own Bash call for explicit approval."; } } - - return next(); }, }; diff --git a/policies/deny-wrangler-pipes.ts b/policies/deny-wrangler-pipes.ts new file mode 100644 index 0000000..b74e4ea --- /dev/null +++ b/policies/deny-wrangler-pipes.ts @@ -0,0 +1,58 @@ +import type { Policy } from "../src"; +import { parseShell, getPipelineCommands, getArgs } from "./parse-bash-ast"; + +const WRANGLERS = new Set([ + "jq", + "fx", + "yq", + "xq", + "htmlq", + "mlr", + "miller", + "jp", + "jpx", +]); + +const STEERING_MESSAGE = `Pipelines like \` | jq ''\` waste the left-hand side when the filter errors out (typo, wrong shape, undefined var). Save once, iterate cheaply: + + Bad: gh issue view 731 --json title,body | fx '.title + (.body || "")' + Good: gh issue view 731 --json title,body > tmp/issue.json + fx '' < tmp/issue.json # iterate without re-running gh + +Read the saved file via the canonical form for each wrangler: + + jq '' tmp/file.json + fx '' < tmp/file.json (fx-as-first-arg has parser traps: leading \`/\` becomes a regex, \`.field\` hits strict-mode) + yq '' tmp/file.yaml + xq -x '' tmp/file.xml + htmlq '' < tmp/file.html + +For ad-hoc exploration, \`gron tmp/file.json | grep \` is often faster than guessing the jq/fx shape — gron flattens to one assignment per line.`; + +const denyWranglerPipes: Policy = { + name: "Deny pipe to data wrangler", + description: + "Denies | jq/fx/yq/xq/htmlq/mlr pipelines — points Claude at save-then-iterate so filter errors don't waste the left-hand side", + action: "deny", + handler: async (call) => { + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; + + const ast = await parseShell(call.args.command); + if (!ast || ast.Stmts.length !== 1) return; + + const segments = getPipelineCommands(ast.Stmts[0]); + if (!segments || segments.length < 2) return; + + // Segments at index > 0 are pipe-RHS by definition. + for (let i = 1; i < segments.length; i++) { + const args = getArgs(segments[i]); + if (!args || args.length === 0) continue; + if (!WRANGLERS.has(args[0])) continue; + // Wrangler with any trailing token (filter or flag) — fire. + // `cmd | jq` alone (no filter) is legal but vanishingly rare; skip it. + if (args.length > 1) return STEERING_MESSAGE; + } + }, +}; +export default denyWranglerPipes; diff --git a/policies/deny-writes-outside-project.ts b/policies/deny-writes-outside-project.ts index 04eba36..71c6765 100644 --- a/policies/deny-writes-outside-project.ts +++ b/policies/deny-writes-outside-project.ts @@ -1,32 +1,49 @@ import { homedir } from "os"; -import { resolve } from "path"; -import { allow, deny, next, isWithinProject, type Policy } from "../src"; +import { join, resolve } from "path"; +import { isWithinProject, type Policy } from "../src"; import { parseShell, findWriteRedirects, findTeeTargets, findWriteCommandTargets, getRedirects, Op, wordToString } from "./parse-bash-ast"; const SAFE_WRITE_TARGETS = new Set(["/dev/null", "/dev/stderr", "/dev/stdout"]); +const MEMORY_PROJECTS_DIR = join(homedir(), ".claude", "projects"); + +/** + * Claude-owned auto-memory files live at ~/.claude/projects//memory/. + * They are an explicit sanctioned write target outside the project root — + * see allow-memory-crud.ts for the matching allow rule. + */ +function isMemoryPath(path: string): boolean { + const expanded = path === "~" ? homedir() + : path.startsWith("~/") ? homedir() + path.slice(1) + : path; + if (!expanded.startsWith(MEMORY_PROJECTS_DIR + "/")) return false; + const rel = expanded.slice(MEMORY_PROJECTS_DIR.length + 1); + return /^[^/]+\/memory\/[^/]+/.test(rel); +} + const denyWritesOutsideProject: Policy = { name: "Deny writes outside project", description: "Blocks file writes and Bash redirects targeting paths outside the project root", + action: "deny", handler: async (call) => { - if (!call.context.projectRoot) return next(); + if (!call.context.projectRoot) return; const projectRoot = call.context.projectRoot; if (call.tool === "Write" || call.tool === "Edit") { const filePath = call.args.file_path; - if (typeof filePath !== "string") return next(); + if (typeof filePath !== "string") return; if (!isInsideProject(filePath, call.context)) { - return deny(`Write blocked: ${filePath} is outside project root (${projectRoot})`); + return `Write blocked: ${filePath} is outside project root (${projectRoot})`; } - return next(); + return; } if (call.tool === "Bash") { const command = call.args.command; - if (typeof command !== "string") return next(); + if (typeof command !== "string") return; const ast = await parseShell(command); - if (!ast) return next(); + if (!ast) return; const cwd = call.context.cwd; @@ -36,7 +53,7 @@ const denyWritesOutsideProject: Policy = { if (!r.target) continue; const resolved = resolvePath(r.target, cwd); if (resolved && !isInsideProject(resolved, call.context)) { - return deny(`Write blocked: redirect target is outside project root (${projectRoot})`); + return `Write blocked: redirect target is outside project root (${projectRoot})`; } } @@ -46,7 +63,7 @@ const denyWritesOutsideProject: Policy = { if (SAFE_WRITE_TARGETS.has(target)) continue; const resolved = resolvePath(target, cwd); if (resolved && !isInsideProject(resolved, call.context)) { - return deny(`Write blocked: redirect target is outside project root (${projectRoot})`); + return `Write blocked: redirect target is outside project root (${projectRoot})`; } } @@ -55,30 +72,18 @@ const denyWritesOutsideProject: Policy = { for (const target of writeTargets) { const resolved = resolvePath(target, cwd); if (resolved && !isInsideProject(resolved, call.context)) { - return deny(`Write blocked: "${target}" is outside project root. Use ./tmp/ within your project instead.`); + return `Write blocked: "${target}" is outside project root. Use ./tmp/ within your project instead.`; } } - - // If there are any safe-only redirects (all unsafe ones would have returned deny above), - // return allow() so the command doesn't get prompted - const allRedirs = getRedirects(ast); - const hasSafeWriteRedirect = allRedirs.some( - (r) => (r.op === Op.RdrOut || r.op === Op.AppOut) && r.target && SAFE_WRITE_TARGETS.has(r.target), - ); - const hasSafeTee = teeTargets.some((t) => SAFE_WRITE_TARGETS.has(t)); - - if (hasSafeWriteRedirect || hasSafeTee) { - return allow(); - } } - - return next(); }, }; export default denyWritesOutsideProject; function isInsideProject(filePath: string, context: { projectRoot: string; additionalDirs: string[] }): boolean { - return SAFE_WRITE_TARGETS.has(filePath) || isWithinProject(filePath, context); + if (SAFE_WRITE_TARGETS.has(filePath)) return true; + if (isMemoryPath(filePath)) return true; + return isWithinProject(filePath, context); } function resolvePath(p: string, cwd: string): string | null { diff --git a/policies/index.ts b/policies/index.ts index 57f3521..1635e7d 100644 --- a/policies/index.ts +++ b/policies/index.ts @@ -7,6 +7,10 @@ import denyGitChained from "./deny-git-chained"; import denyGhHeredoc from "./deny-gh-heredoc"; import denySshCompound from "./deny-ssh-compound"; import denyMixedPureChains from "./deny-mixed-pure-chains"; +import denyPerlOneLiners from "./deny-perl-one-liners"; +import denyWranglerPipes from "./deny-wrangler-pipes"; +import redirectTrivialWranglerToRead from "./redirect-trivial-wrangler-to-read"; +import denyAndChains from "./deny-and-chains"; import redirectPythonJsonToFx from "./redirect-python-json-to-fx"; import redirectPlansToProject from "./redirect-plans-to-project"; import allowBunTest from "./allow-bun-test"; @@ -15,7 +19,7 @@ import allowGitDiff from "./allow-git-diff"; import allowGitLog from "./allow-git-log"; import allowGitStatus from "./allow-git-status"; import allowGrepInProject from "./allow-grep-in-project"; -import allowLsInProject from "./allow-ls-in-project"; +import allowLs from "./allow-ls"; import allowAgent from "./allow-agent"; import allowExploreInProject from "./allow-explore-in-project"; import allowReadInProject from "./allow-read-in-project"; @@ -25,7 +29,7 @@ import allowPlanInProject from "./allow-plan-in-project"; import allowWebFetchClaude from "./allow-webfetch-claude"; import allowTaskCrud from "./allow-task-crud"; import allowGhReadOnly from "./allow-gh-read-only"; -import allowBashFindInProject from "./allow-bash-find-in-project"; +import allowBashFind from "./allow-bash-find"; import allowSuperpowersSkills from "./allow-superpowers-skills"; import allowGitCheckIgnore from "./allow-git-check-ignore"; import allowGitRevParse from "./allow-git-rev-parse"; @@ -46,20 +50,28 @@ import allowMcpContext7 from "./allow-mcp-context7"; import allowMcpIdeDiagnostics from "./allow-mcp-ide-diagnostics"; import allowMcpPlaywright from "./allow-mcp-playwright"; import allowPlanMode from "./allow-plan-mode"; +import allowMagickInProject from "./allow-magick-in-project"; import allowMkdirInProject from "./allow-mkdir-in-project"; import allowAskUser from "./allow-ask-user"; import allowToolSearch from "./allow-tool-search"; import allowGitLocalRepo from "./allow-git-local-repo"; import allowCronCrud from "./allow-cron-crud"; import allowRmProjectTmp from "./allow-rm-project-tmp"; +import allowRmdirProjectTmp from "./allow-rmdir-project-tmp"; import allowNpmInstall from "./allow-npm-install"; import allowNpxSafe from "./allow-npx-safe"; import allowSleep from "./allow-sleep"; +import allowLsof from "./allow-lsof"; +import allowWhich from "./allow-which"; +import allowVersionProbes from "./allow-version-probes"; +import allowDate from "./allow-date"; import allowNonDestructiveGit from "./allow-non-destructive-git"; import allowGhIssuePr from "./allow-gh-issue-pr"; import allowTmux from "./allow-tmux"; import allowAwsCli from "./allow-aws-cli"; import allowBrew from "./allow-brew"; +import allowToolgateTest from "./allow-toolgate-test"; +import allowMemoryCrud from "./allow-memory-crud"; export const builtinPolicies = [ denyGitAddAndCommit, @@ -72,13 +84,17 @@ export const builtinPolicies = [ denyGhHeredoc, denySshCompound, denyMixedPureChains, + denyPerlOneLiners, + denyWranglerPipes, + redirectTrivialWranglerToRead, + denyAndChains, allowBunTest, allowGitAdd, allowGitDiff, allowGitLog, allowGitStatus, allowGrepInProject, - allowLsInProject, + allowLs, allowAgent, allowExploreInProject, allowReadInProject, @@ -89,7 +105,7 @@ export const builtinPolicies = [ allowTaskCrud, allowGhReadOnly, allowGhIssuePr, - allowBashFindInProject, + allowBashFind, allowBashGrepInProject, allowSuperpowersSkills, allowGitCheckIgnore, @@ -111,17 +127,25 @@ export const builtinPolicies = [ allowMcpContext7, allowMcpIdeDiagnostics, allowMcpPlaywright, + allowMagickInProject, allowMkdirInProject, allowPlanMode, allowAskUser, allowToolSearch, allowCronCrud, allowRmProjectTmp, + allowRmdirProjectTmp, allowNpmInstall, allowNpxSafe, allowSleep, + allowLsof, + allowWhich, + allowVersionProbes, + allowDate, allowNonDestructiveGit, allowTmux, allowAwsCli, allowBrew, + allowToolgateTest, + allowMemoryCrud, ]; diff --git a/policies/parse-bash-ast.ts b/policies/parse-bash-ast.ts index 5acc3d4..a82317c 100644 --- a/policies/parse-bash-ast.ts +++ b/policies/parse-bash-ast.ts @@ -430,6 +430,65 @@ const UNCONDITIONALLY_SAFE = new Set([ "gron", ]); +/** rg flags that consume the next token as their value. */ +const RG_FLAGS_WITH_VALUE = new Set([ + "-r", "--replace", + "-e", "--regexp", + "-g", "--glob", "--iglob", + "-A", "-B", "-C", "--after-context", "--before-context", "--context", + "-t", "--type", "-T", "--type-not", + "-m", "--max-count", + "-E", "--encoding", + "--sort", "--sortr", + "-j", "--threads", + "--engine", + "--max-columns", "--max-depth", "--max-filesize", + "--color", "--colors", + "--context-separator", "--field-context-separator", "--field-match-separator", + "--path-separator", +]); + +/** xq (Go variant) flags that consume the next token as their value. */ +const XQ_FLAGS_WITH_VALUE = new Set([ + "-x", "--xpath", + "-q", "--query", + "-a", "--attr", +]); + +function looksLikePath(arg: string): boolean { + return arg.startsWith("/") || arg.startsWith("~"); +} + +function hasAnyToken(tokens: string[], exact: string[], prefixes: string[]): boolean { + for (const t of tokens) { + if (exact.includes(t)) return true; + for (const p of prefixes) { + if (t.startsWith(p)) return true; + } + } + return false; +} + +/** Extract positional (non-flag) args, accounting for flags that consume their next token as a value. */ +function getPositionals(tokens: string[], flagsWithValue: Set): string[] { + const positionals: string[] = []; + let i = 1; + while (i < tokens.length) { + const t = tokens[i]; + if (flagsWithValue.has(t)) { + i += 2; + continue; + } + if (t.startsWith("-")) { + i++; + continue; + } + positionals.push(t); + i++; + } + return positionals; +} + export function isSafeFilter(tokens: string[]): boolean { if (tokens.length === 0) return false; const cmd = tokens[0]; @@ -458,6 +517,71 @@ export function isSafeFilter(tokens: string[]): boolean { return nonFlags.length <= 1; } + if (cmd === "rg") { + // Block flags that exec commands, shell out to decompressors, or read arbitrary files + const blockedExact = [ + "--pre", "--preprocessor", "--pre-glob", "--hostname-bin", + "-z", "--search-zip", + "-f", "--file", "--ignore-file", + ]; + const blockedPrefix = [ + "--pre=", "--preprocessor=", "--pre-glob=", "--hostname-bin=", + "--file=", "--ignore-file=", + ]; + if (hasAnyToken(tokens.slice(1), blockedExact, blockedPrefix)) return false; + // Block path-like positionals (mid-pipeline rg should read stdin only) + const positionals = getPositionals(tokens, RG_FLAGS_WITH_VALUE); + for (const p of positionals) { + if (looksLikePath(p)) return false; + } + return true; + } + + if (cmd === "sd") { + // sd with positional file args mutates those files in place. + // As a stdin filter it takes exactly 2 positional args: . + const nonFlags = tokens.slice(1).filter((t) => !t.startsWith("-")); + return nonFlags.length === 2; + } + + if (cmd === "choose") { + // -i / --input reads from an arbitrary file path (exfil vector) + const blockedExact = ["-i", "--input"]; + const blockedPrefix = ["--input="]; + if (hasAnyToken(tokens.slice(1), blockedExact, blockedPrefix)) return false; + return true; + } + + if (cmd === "xq") { + // Block in-place writes, arbitrary file reads (jq Python wrapper flags), and module loading + const blockedExact = [ + "-i", "--in-place", + "--rawfile", "--slurpfile", + "-f", "--from-file", + "-L", "--library-path", + ]; + const blockedPrefix = [ + "--in-place=", "--rawfile=", "--slurpfile=", + "--from-file=", "--library-path=", + ]; + if (hasAnyToken(tokens.slice(1), blockedExact, blockedPrefix)) return false; + // Mid-pipeline xq should read stdin; reject bare positional file args + const positionals = getPositionals(tokens, XQ_FLAGS_WITH_VALUE); + if (positionals.length > 1) return false; + for (const p of positionals) { + if (looksLikePath(p)) return false; + } + return true; + } + + if (cmd === "htmlq") { + // -f / --filename reads arbitrary files; -o / --output writes them + const blockedExact = ["-f", "--filename", "-o", "--output"]; + const blockedPrefix = ["--filename=", "--output="]; + if (hasAnyToken(tokens.slice(1), blockedExact, blockedPrefix)) return false; + return true; + } + return false; } diff --git a/policies/redirect-plans-to-project.ts b/policies/redirect-plans-to-project.ts index 06f9d43..90aaf75 100644 --- a/policies/redirect-plans-to-project.ts +++ b/policies/redirect-plans-to-project.ts @@ -1,4 +1,4 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, getRedirects, findTeeTargets, Op } from "./parse-bash-ast"; const GLOBAL_PLANS_DIR = "/.claude/plans"; @@ -6,33 +6,34 @@ const GLOBAL_PLANS_DIR = "/.claude/plans"; const redirectPlansToProject: Policy = { name: "Redirect plans to project", description: "Blocks plan writes to ~/.claude/plans/ and suggests project docs/ instead", + action: "deny", handler: async (call) => { - if (!call.context.projectRoot) return next(); + if (!call.context.projectRoot) return; const projectRoot = call.context.projectRoot; const docsDir = `${projectRoot}/docs`; if (call.tool === "Write" || call.tool === "Edit") { const filePath = call.args.file_path; - if (typeof filePath !== "string") return next(); + if (typeof filePath !== "string") return; if (isGlobalPlanPath(filePath)) { - return deny(`Plan files should be saved in the project, not globally. Write to ${docsDir}/ instead of ${filePath}`); + return `Plan files should be saved in the project, not globally. Write to ${docsDir}/ instead of ${filePath}`; } - return next(); + return; } if (call.tool === "Bash") { const command = call.args.command; - if (typeof command !== "string") return next(); + if (typeof command !== "string") return; const ast = await parseShell(command); - if (!ast) return next(); + if (!ast) return; // Check write redirects const allRedirs = getRedirects(ast); for (const r of allRedirs) { if (r.op !== Op.RdrOut && r.op !== Op.AppOut) continue; if (r.target && isGlobalPlanPath(r.target)) { - return deny(`Plan files should be saved in the project, not globally. Write to ${docsDir}/ instead of ${r.target}`); + return `Plan files should be saved in the project, not globally. Write to ${docsDir}/ instead of ${r.target}`; } } @@ -40,12 +41,10 @@ const redirectPlansToProject: Policy = { const teeTargets = findTeeTargets(ast); for (const target of teeTargets) { if (isGlobalPlanPath(target)) { - return deny(`Plan files should be saved in the project, not globally. Write to ${docsDir}/ instead of ${target}`); + return `Plan files should be saved in the project, not globally. Write to ${docsDir}/ instead of ${target}`; } } } - - return next(); }, }; export default redirectPlansToProject; diff --git a/policies/redirect-python-json-to-fx.ts b/policies/redirect-python-json-to-fx.ts index d99ee16..4563b47 100644 --- a/policies/redirect-python-json-to-fx.ts +++ b/policies/redirect-python-json-to-fx.ts @@ -1,4 +1,4 @@ -import { deny, next, type Policy } from "../src"; +import type { Policy } from "../src"; import { parseShell, getPipelineCommands, getArgs } from "./parse-bash-ast"; const JSON_DESCRIPTION_PATTERNS = [ @@ -44,8 +44,8 @@ function isPythonJsonTool(cmds: ReturnType): boolean } const DENY_MESSAGE = - "Use `fx` for JSON extraction (`| fx '.field.subfield'`), " + - "`gron` for path discovery (`| gron | grep key`), " + + "Use `fx` for JSON extraction (save to `tmp/file.json` first, then `fx '' < tmp/file.json` — piping into fx is denied to avoid wasting the LHS), " + + "`gron` for path discovery (`gron tmp/file.json | grep key`), " + "or the Read tool to inspect JSON files directly. " + "Only use Python for complex transforms that genuinely need it (atomic file writes, multi-step logic with non-JSON inputs)."; @@ -53,21 +53,22 @@ const redirectPythonJsonToFx: Policy = { name: "Redirect python JSON to fx", description: "Blocks python3 -m json.tool (always) and python3 commands whose description suggests JSON processing — suggests fx/gron instead", + action: "deny", handler: async (call) => { - if (call.tool !== "Bash") return next(); - if (typeof call.args.command !== "string") return next(); + if (call.tool !== "Bash") return; + if (typeof call.args.command !== "string") return; const ast = await parseShell(call.args.command); - if (!ast || ast.Stmts.length !== 1) return next(); + if (!ast || ast.Stmts.length !== 1) return; const cmds = getPipelineCommands(ast.Stmts[0]); - if (!cmds) return next(); + if (!cmds) return; // Hard block: python3 -m json.tool is always just pretty-printing if (isPythonJsonTool(cmds)) { - return deny( + return ( "`python3 -m json.tool` is just pretty-printing. Use `| fx .` instead, or `| fx` for interactive browsing. " + - DENY_MESSAGE, + DENY_MESSAGE ); } @@ -75,11 +76,9 @@ const redirectPythonJsonToFx: Policy = { if (commandContainsPython(cmds)) { const description = call.args.description; if (typeof description === "string" && descriptionSuggestsJsonProcessing(description)) { - return deny(DENY_MESSAGE); + return DENY_MESSAGE; } } - - return next(); }, }; export default redirectPythonJsonToFx; diff --git a/policies/redirect-trivial-wrangler-to-read.ts b/policies/redirect-trivial-wrangler-to-read.ts new file mode 100644 index 0000000..6c722e7 --- /dev/null +++ b/policies/redirect-trivial-wrangler-to-read.ts @@ -0,0 +1,95 @@ +import { statSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Policy } from "../src"; +import { safeBashCommand } from "./parse-bash-ast"; + +const SMALL_FILE_THRESHOLD = 10 * 1024; +const TRIVIAL_FILTERS = new Set([".", "_"]); +const WRANGLERS = new Set(["jq", "fx", "yq", "gron"]); + +const STEERING_MESSAGE = `For files this small (<10KB), use the Read tool instead of a wrangler: + - No filter syntax to typo + - Full content lands directly in your context (paginatable) + - No subprocess fork + +Use: Read({ file_path: "" }) + +Reach for jq/fx/yq/gron only when: + - The file is too big to context-inject (>~10KB) + - You need a computed slim (sum/count/group-by/filter) that produces less output than the input + - You're piping the result to another tool + +If you actually want a filtered subset of this small file, use a real filter: + jq '.users[].name' tmp/file.json (real filter — keep this) + jq . tmp/file.json (pretty-print only — use Read)`; + +function expandHome(path: string): string { + if (path === "~") return process.env.HOME ?? path; + if (path.startsWith("~/")) { + return process.env.HOME ? path.replace(/^~/, process.env.HOME) : path; + } + return path; +} + +function tryStatSize(cwd: string, path: string): number | null { + try { + const expanded = expandHome(path); + const full = expanded.startsWith("/") ? expanded : resolve(cwd, expanded); + const stat = statSync(full); + if (!stat.isFile()) return null; + return stat.size; + } catch { + return null; + } +} + +function getPositionals(rest: string[]): string[] { + return rest.filter((t) => !t.startsWith("-")); +} + +const redirectTrivialWranglerToRead: Policy = { + name: "Redirect trivial wrangler to Read", + description: + "When jq/fx/yq/gron is invoked with a trivial (or no) filter on a small (<10KB) file, steer to the Read tool", + action: "deny", + handler: async (call) => { + const args = await safeBashCommand(call); + if (!args || args.length === 0) return; + + const wrangler = args[0]; + if (!WRANGLERS.has(wrangler)) return; + + const positionals = getPositionals(args.slice(1)); + if (positionals.length === 0) return; + + // Find one positional that's an existing file; the rest are filter args. + const cwd = (call.context as any)?.cwd ?? process.cwd(); + let fileSize: number | null = null; + const nonFileArgs: string[] = []; + for (const p of positionals) { + if (fileSize === null) { + const size = tryStatSize(cwd, p); + if (size !== null) { + fileSize = size; + continue; + } + } + nonFileArgs.push(p); + } + + if (fileSize === null) return; + if (fileSize >= SMALL_FILE_THRESHOLD) return; + + // gron has no filter syntax — always trivial. + if (wrangler === "gron") return STEERING_MESSAGE; + + // No filter args (e.g. `jq file.json`) → defaults to identity → trivial. + if (nonFileArgs.length === 0) return STEERING_MESSAGE; + + // Single trivial filter token (".", "_") → trivial. + if (nonFileArgs.length === 1 && TRIVIAL_FILTERS.has(nonFileArgs[0])) { + return STEERING_MESSAGE; + } + }, +}; +export default redirectTrivialWranglerToRead; diff --git a/policies/tests/allow-ask-user.test.ts b/policies/tests/allow-ask-user.test.ts index 570b333..3429382 100644 --- a/policies/tests/allow-ask-user.test.ts +++ b/policies/tests/allow-ask-user.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowAskUser from "../allow-ask-user"; +const run = adaptHandler(allowAskUser.action!, allowAskUser.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record = {}): ToolCall => ({ @@ -12,12 +14,12 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-ask-user", () => { it("allows AskUserQuestion", async () => { - const result = await allowAskUser.handler(makeCall("AskUserQuestion", { question: "What should I do?" })); + const result = await run(makeCall("AskUserQuestion", { question: "What should I do?" })); expect(result.verdict).toBe(ALLOW); }); it("passes through non-AskUserQuestion tools", async () => { - const result = await allowAskUser.handler(makeCall("Bash", { command: "echo hello" })); + const result = await run(makeCall("Bash", { command: "echo hello" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-aws-cli.test.ts b/policies/tests/allow-aws-cli.test.ts index 605495a..7f3f844 100644 --- a/policies/tests/allow-aws-cli.test.ts +++ b/policies/tests/allow-aws-cli.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowAwsCli, { createAwsCliPolicy } from "../allow-aws-cli"; const PROJECT = "/home/user/project"; +const run = adaptHandler(allowAwsCli.action!, allowAwsCli.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -32,7 +34,7 @@ describe("allow-aws-cli", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowAwsCli.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -60,7 +62,7 @@ describe("allow-aws-cli", () => { for (const cmd of requireApproval) { it(`requires approval: ${cmd}`, async () => { - const result = await allowAwsCli.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -78,7 +80,7 @@ describe("allow-aws-cli", () => { for (const cmd of requireApproval) { it(`requires approval: ${cmd}`, async () => { - const result = await allowAwsCli.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -93,7 +95,7 @@ describe("allow-aws-cli", () => { for (const cmd of fallThrough) { it(`falls through: ${cmd}`, async () => { - const result = await allowAwsCli.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -106,29 +108,29 @@ describe("allow-aws-cli", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowAwsCli.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); it("ignores non-aws bash commands", async () => { - const result = await allowAwsCli.handler(bash("git status")); + const result = await run(bash("git status")); expect(result.verdict).toBe(NEXT); }); it("ignores compound commands", async () => { - const result = await allowAwsCli.handler(bash("aws s3 ls && aws s3 rm s3://bucket/key")); + const result = await run(bash("aws s3 ls && aws s3 rm s3://bucket/key")); expect(result.verdict).toBe(NEXT); }); }); describe("case-insensitive profile matching", () => { it("matches readonly case-insensitively", async () => { - const result = await allowAwsCli.handler(bash("aws s3 ls --profile ko-readonly")); + const result = await run(bash("aws s3 ls --profile ko-readonly")); expect(result.verdict).toBe(ALLOW); }); it("matches admin case-insensitively", async () => { - const result = await allowAwsCli.handler(bash("aws s3 ls --profile ko-admin")); + const result = await run(bash("aws s3 ls --profile ko-admin")); expect(result.verdict).toBe(NEXT); }); }); @@ -141,7 +143,7 @@ describe("allow-aws-cli", () => { for (const cmd of requireApproval) { it(`requires approval: ${cmd}`, async () => { - const result = await allowAwsCli.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -155,51 +157,52 @@ describe("createAwsCliPolicy (custom config)", () => { restrictedAccountIds: ["111111111111", "222222222222"], extraDestructiveSubcommands: ["stop-instances", "reboot-instances"], }); + const runCustom = adaptHandler(custom.action!, custom.handler as any); describe("uses custom readOnly profiles", () => { it("allows SecurityAudit profile", async () => { - const result = await custom.handler(bash("aws s3 ls --profile ko-SecurityAudit")); + const result = await runCustom(bash("aws s3 ls --profile ko-SecurityAudit")); expect(result.verdict).toBe(ALLOW); }); it("allows ViewOnly profile", async () => { - const result = await custom.handler(bash("aws ec2 describe-instances --profile ViewOnly")); + const result = await runCustom(bash("aws ec2 describe-instances --profile ViewOnly")); expect(result.verdict).toBe(ALLOW); }); it("does NOT auto-allow default ReadOnly when overridden", async () => { - const result = await custom.handler(bash("aws s3 ls --profile ko-ReadOnly")); + const result = await runCustom(bash("aws s3 ls --profile ko-ReadOnly")); expect(result.verdict).toBe(NEXT); }); }); describe("uses custom admin profiles", () => { it("requires approval for PowerUser", async () => { - const result = await custom.handler(bash("aws s3 ls --profile ko-PowerUser")); + const result = await runCustom(bash("aws s3 ls --profile ko-PowerUser")); expect(result.verdict).toBe(NEXT); }); it("requires approval for FullAccess", async () => { - const result = await custom.handler(bash("aws s3 ls --profile staging-FullAccess")); + const result = await runCustom(bash("aws s3 ls --profile staging-FullAccess")); expect(result.verdict).toBe(NEXT); }); it("does NOT flag default Admin when overridden", async () => { - const result = await custom.handler(bash("aws s3 ls --profile ko-Admin")); + const result = await runCustom(bash("aws s3 ls --profile ko-Admin")); expect(result.verdict).toBe(NEXT); }); }); describe("requires approval for restricted account IDs", () => { it("requires approval for custom account ID", async () => { - const result = await custom.handler( + const result = await runCustom( bash("aws sts assume-role --role-arn arn:aws:iam::111111111111:role/Role --profile ko-SecurityAudit"), ); expect(result.verdict).toBe(NEXT); }); it("does not restrict when no account ID mentioned", async () => { - const result = await custom.handler( + const result = await runCustom( bash("aws s3 ls --profile ko-SecurityAudit"), ); expect(result.verdict).toBe(ALLOW); @@ -208,21 +211,21 @@ describe("createAwsCliPolicy (custom config)", () => { describe("uses extra destructive subcommands", () => { it("requires approval for stop-instances", async () => { - const result = await custom.handler( + const result = await runCustom( bash("aws ec2 stop-instances --instance-ids i-123 --profile ko-SecurityAudit"), ); expect(result.verdict).toBe(NEXT); }); it("requires approval for reboot-instances", async () => { - const result = await custom.handler( + const result = await runCustom( bash("aws ec2 reboot-instances --instance-ids i-123"), ); expect(result.verdict).toBe(NEXT); }); it("still catches built-in destructive commands", async () => { - const result = await custom.handler( + const result = await runCustom( bash("aws s3 rm s3://bucket/key --profile ko-SecurityAudit"), ); expect(result.verdict).toBe(NEXT); diff --git a/policies/tests/allow-bash-find-in-project.test.ts b/policies/tests/allow-bash-find.test.ts similarity index 65% rename from policies/tests/allow-bash-find-in-project.test.ts rename to policies/tests/allow-bash-find.test.ts index cfeb5ec..79ad042 100644 --- a/policies/tests/allow-bash-find-in-project.test.ts +++ b/policies/tests/allow-bash-find.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; -import allowBashFindInProject from "../allow-bash-find-in-project"; +import { homedir } from "node:os"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowBashFind from "../allow-bash-find"; -const PROJECT = "/home/user/project"; +const run = adaptHandler(allowBashFind.action!, allowBashFind.handler as any); + +const HOME = homedir(); +const PROJECT = `${HOME}/some-project`; function bash(command: string, cwd = PROJECT, projectRoot: string | null = PROJECT): ToolCall { return { @@ -12,8 +16,8 @@ function bash(command: string, cwd = PROJECT, projectRoot: string | null = PROJE }; } -describe("allow-bash-find-in-project", () => { - describe("allows find within project", () => { +describe("allow-bash-find", () => { + describe("allows find under $HOME", () => { const allowed = [ "find", "find .", @@ -25,43 +29,47 @@ describe("allow-bash-find-in-project", () => { `find ${PROJECT}/src`, `find ${PROJECT}`, `find ${PROJECT} -name '*.ts'`, + `find ${HOME}/Dev`, + `find ${HOME}/.claude -name '*.json'`, + `find ${HOME}`, + "find ~/.claude -name '*.json'", + "find ~/Dev -maxdepth 2 -name '*.json'", + "find ~", "find . -name '*.ts' -o -name '*.js'", - "find . -type f -name '*.blade.php' -o -name '*.tsx' -o -name '*.ts'", - "find . -name 'main.ts' -o -name 'main.tsx'", ]; for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowBashFindInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } }); - describe("rejects find outside project", () => { + describe("rejects find outside $HOME", () => { const rejected = [ "find /etc", - "find /home/user/other-project", "find /tmp", - `find ${PROJECT}-evil`, + "find /Applications", + "find /var/log", ]; for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowBashFindInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } }); - describe("rejects bare find when cwd is outside project", () => { + describe("rejects bare find when cwd is outside $HOME", () => { it("rejects find in /tmp", async () => { - const result = await allowBashFindInProject.handler(bash("find", "/tmp")); + const result = await run(bash("find", "/tmp")); expect(result.verdict).toBe(NEXT); }); it("rejects find . in /tmp", async () => { - const result = await allowBashFindInProject.handler(bash("find .", "/tmp")); + const result = await run(bash("find .", "/tmp")); expect(result.verdict).toBe(NEXT); }); }); @@ -75,7 +83,7 @@ describe("allow-bash-find-in-project", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowBashFindInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -86,16 +94,15 @@ describe("allow-bash-find-in-project", () => { "find . -name '*.ts' | head -10", "find . -name '*.ts' | grep src", "find . -type f | wc -l", - "find . -name '*.php' | grep -i controller | head -5", `find ${PROJECT}/src -name '*.ts' | sort`, "find . | tail -20", "find . -name '*.ts' | cut -d/ -f2 | sort | uniq", - "find resources -type f | grep -E '(form|Form)' | sort | head -80", + `find ${HOME}/.claude -name '*.json' | head -20`, ]; for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowBashFindInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -107,12 +114,11 @@ describe("allow-bash-find-in-project", () => { "find . | sh -c 'cat'", "find . | tee /tmp/out", "find . -name '*.ts' | sort -o outfile", - "find . | uniq input output", ]; for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowBashFindInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -134,7 +140,7 @@ describe("allow-bash-find-in-project", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowBashFindInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -147,19 +153,14 @@ describe("allow-bash-find-in-project", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowBashFindInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } }); - it("passes through when no project root", async () => { - const result = await allowBashFindInProject.handler(bash("find .", PROJECT, null)); - expect(result.verdict).toBe(NEXT); - }); - it("passes through non-find commands", async () => { - const result = await allowBashFindInProject.handler(bash("ls -la")); + const result = await run(bash("ls -la")); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-brew.test.ts b/policies/tests/allow-brew.test.ts index b403b72..a1eecdb 100644 --- a/policies/tests/allow-brew.test.ts +++ b/policies/tests/allow-brew.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowBrew from "../allow-brew"; +const run = adaptHandler(allowBrew.action!, allowBrew.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string): ToolCall { @@ -47,7 +49,7 @@ describe("allow-brew", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowBrew.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -61,7 +63,7 @@ describe("allow-brew", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowBrew.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -97,7 +99,7 @@ describe("allow-brew", () => { for (const cmd of requireApproval) { it(`requires approval: ${cmd}`, async () => { - const result = await allowBrew.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -110,23 +112,23 @@ describe("allow-brew", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowBrew.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); it("ignores non-brew bash commands", async () => { - const result = await allowBrew.handler(bash("git status")); + const result = await run(bash("git status")); expect(result.verdict).toBe(NEXT); }); it("ignores compound commands", async () => { - const result = await allowBrew.handler(bash("brew list && brew install node")); + const result = await run(bash("brew list && brew install node")); expect(result.verdict).toBe(NEXT); }); }); it("falls through for bare brew with no subcommand", async () => { - const result = await allowBrew.handler(bash("brew")); + const result = await run(bash("brew")); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-bun-test.test.ts b/policies/tests/allow-bun-test.test.ts index 0a42dff..5ae14b1 100644 --- a/policies/tests/allow-bun-test.test.ts +++ b/policies/tests/allow-bun-test.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowBunTest from "../allow-bun-test"; +const run = adaptHandler(allowBunTest.action!, allowBunTest.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -26,7 +28,7 @@ describe("allow-bun-test", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowBunTest.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -44,7 +46,7 @@ describe("allow-bun-test", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowBunTest.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -59,7 +61,7 @@ describe("allow-bun-test", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowBunTest.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -73,7 +75,7 @@ describe("allow-bun-test", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowBunTest.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -87,7 +89,7 @@ describe("allow-bun-test", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowBunTest.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -104,7 +106,7 @@ describe("allow-bun-test", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowBunTest.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -116,7 +118,7 @@ describe("allow-bun-test", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await allowBunTest.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-cd-in-project.test.ts b/policies/tests/allow-cd-in-project.test.ts index 43b4bf6..704e818 100644 --- a/policies/tests/allow-cd-in-project.test.ts +++ b/policies/tests/allow-cd-in-project.test.ts @@ -1,8 +1,10 @@ import { homedir } from "node:os"; import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowCdInProject from "../allow-cd-in-project"; +const run = adaptHandler(allowCdInProject.action!, allowCdInProject.handler as any); + const HOME = homedir(); const PROJECT = `${HOME}/Dev/myproject`; @@ -29,7 +31,7 @@ describe("allow-cd-in-project", () => { for (const { cmd, desc, cwd } of allowed) { it(`allows: ${cmd} (${desc})`, async () => { - const result = await allowCdInProject.handler(bash(cmd, cwd)); + const result = await run(bash(cmd, cwd)); expect(result.verdict).toBe(ALLOW); }); } @@ -44,7 +46,7 @@ describe("allow-cd-in-project", () => { for (const { cmd, desc, cwd } of passThrough) { it(`next: ${cmd} (${desc})`, async () => { - const result = await allowCdInProject.handler(bash(cmd, cwd)); + const result = await run(bash(cmd, cwd)); expect(result.verdict).toBe(NEXT); }); } @@ -62,17 +64,17 @@ describe("allow-cd-in-project", () => { } it("allows: cd to project root", async () => { - const result = await allowCdInProject.handler(bashFor(`cd ${OTHER_PROJECT}`, OTHER_PROJECT)); + const result = await run(bashFor(`cd ${OTHER_PROJECT}`, OTHER_PROJECT)); expect(result.verdict).toBe(ALLOW); }); it("allows: cd ~/Dev/ (tilde to project root)", async () => { - const result = await allowCdInProject.handler(bashFor("cd ~/Dev/acme-app", OTHER_PROJECT)); + const result = await run(bashFor("cd ~/Dev/acme-app", OTHER_PROJECT)); expect(result.verdict).toBe(ALLOW); }); it("allows: cd ~/Dev//.claude/worktrees/... (tilde to worktree inside project)", async () => { - const result = await allowCdInProject.handler( + const result = await run( bashFor("cd ~/Dev/acme-app/.claude/worktrees/feature-branch", OTHER_PROJECT), ); expect(result.verdict).toBe(ALLOW); @@ -81,12 +83,12 @@ describe("allow-cd-in-project", () => { describe("passes through non-cd commands", () => { it("next: bare cd (goes home)", async () => { - const result = await allowCdInProject.handler(bash("cd")); + const result = await run(bash("cd")); expect(result.verdict).toBe(NEXT); }); it("next: ls command", async () => { - const result = await allowCdInProject.handler(bash("ls -la")); + const result = await run(bash("ls -la")); expect(result.verdict).toBe(NEXT); }); @@ -96,7 +98,7 @@ describe("allow-cd-in-project", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowCdInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-cron-crud.test.ts b/policies/tests/allow-cron-crud.test.ts index 8c11fb6..1fa724c 100644 --- a/policies/tests/allow-cron-crud.test.ts +++ b/policies/tests/allow-cron-crud.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowCronCrud from "../allow-cron-crud"; +const run = adaptHandler(allowCronCrud.action!, allowCronCrud.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record = {}): ToolCall => ({ @@ -13,18 +15,18 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-cron-crud", () => { for (const tool of ["CronCreate", "CronDelete", "CronList"]) { it(`allows ${tool}`, async () => { - const result = await allowCronCrud.handler(makeCall(tool)); + const result = await run(makeCall(tool)); expect(result.verdict).toBe(ALLOW); }); } it("passes through non-Cron tools", async () => { - const result = await allowCronCrud.handler(makeCall("Bash", { command: "echo hello" })); + const result = await run(makeCall("Bash", { command: "echo hello" })); expect(result.verdict).toBe(NEXT); }); it("passes through other tools", async () => { - const result = await allowCronCrud.handler(makeCall("Read", { file_path: "/foo" })); + const result = await run(makeCall("Read", { file_path: "/foo" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-date.test.ts b/policies/tests/allow-date.test.ts new file mode 100644 index 0000000..ee430a5 --- /dev/null +++ b/policies/tests/allow-date.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowDate from "../allow-date"; + +const run = adaptHandler(allowDate.action!, allowDate.handler as any); + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; +} + +describe("allow-date", () => { + describe("allows safe date commands", () => { + const allowed = [ + "date", + "date +%Y-%m-%d", + "date '+%H:%M:%S'", + "date -u", + "date --utc", + "date -R", + "date --rfc-3339=seconds", + "date --iso-8601=ns", + "date -Iseconds", + "date -d 'next monday'", + "date --date='2025-01-01'", + "date -d '2025-01-01' +%s", + "date -r 1700000000", + "date -u +%s", + "date +%z && date +%Z", + "date && date +%s", + "date +%s && date -u +%s && date -R", + ]; + + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("rejects unsafe patterns", () => { + const rejected = [ + "date -s '2025-01-01'", + "date --set='2025-01-01'", + "date 010100002025", + "date && rm -rf /", + "date; curl evil.com", + "date $(whoami)", + "date | cat", + "date && rm -rf /tmp/x", + "date +%s && date -s '2025-01-01'", + "date || echo fallback", + "date; date", + ]; + + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + it("passes through non-Bash tools", async () => { + const call: ToolCall = { + tool: "Read", + args: {}, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; + const result = await run(call); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through non-date bash commands", async () => { + const result = await run(bash("echo hello")); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/allow-edit-in-project.test.ts b/policies/tests/allow-edit-in-project.test.ts index 96e50e1..7d78da8 100644 --- a/policies/tests/allow-edit-in-project.test.ts +++ b/policies/tests/allow-edit-in-project.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "bun:test"; import { homedir } from "os"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowEditInProject from "../allow-edit-in-project"; +const run = adaptHandler(allowEditInProject.action!, allowEditInProject.handler as any); + const PROJECT = "/home/user/project"; const HOME_PROJECT = `${homedir()}/myproject`; @@ -25,7 +27,7 @@ describe("allow-edit-in-project", () => { for (const path of allowed) { it(`allows: ${path}`, async () => { - const result = await allowEditInProject.handler(tool(toolName, path)); + const result = await run(tool(toolName, path)); expect(result.verdict).toBe(ALLOW); }); } @@ -41,7 +43,7 @@ describe("allow-edit-in-project", () => { for (const path of outside) { it(`next: ${path}`, async () => { - const result = await allowEditInProject.handler(tool(toolName, path)); + const result = await run(tool(toolName, path)); expect(result.verdict).toBe(NEXT); }); } @@ -70,7 +72,7 @@ describe("allow-edit-in-project", () => { for (const path of sensitive) { it(`next: ${path}`, async () => { - const result = await allowEditInProject.handler(tool("Edit", path)); + const result = await run(tool("Edit", path)); expect(result.verdict).toBe(NEXT); }); } @@ -78,28 +80,28 @@ describe("allow-edit-in-project", () => { describe("resolves tilde paths", () => { it("allows ~/project/file.ts when projectRoot matches", async () => { - const result = await allowEditInProject.handler(tool("Edit", "~/myproject/src/index.ts", HOME_PROJECT)); + const result = await run(tool("Edit", "~/myproject/src/index.ts", HOME_PROJECT)); expect(result.verdict).toBe(ALLOW); }); it("next for ~/project/file.ts when projectRoot differs", async () => { - const result = await allowEditInProject.handler(tool("Edit", "~/other/file.ts", HOME_PROJECT)); + const result = await run(tool("Edit", "~/other/file.ts", HOME_PROJECT)); expect(result.verdict).toBe(NEXT); }); it("next for tilde path to sensitive file", async () => { - const result = await allowEditInProject.handler(tool("Edit", "~/myproject/.env", HOME_PROJECT)); + const result = await run(tool("Edit", "~/myproject/.env", HOME_PROJECT)); expect(result.verdict).toBe(NEXT); }); }); it("passes through non-Edit/Write tools", async () => { - const result = await allowEditInProject.handler(tool("Bash", `${PROJECT}/file.ts`)); + const result = await run(tool("Bash", `${PROJECT}/file.ts`)); expect(result.verdict).toBe(NEXT); }); it("passes through when no project root", async () => { - const result = await allowEditInProject.handler(tool("Edit", `${PROJECT}/file.ts`, null)); + const result = await run(tool("Edit", `${PROJECT}/file.ts`, null)); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-find-in-project.test.ts b/policies/tests/allow-find-in-project.test.ts index eb959f2..f91950e 100644 --- a/policies/tests/allow-find-in-project.test.ts +++ b/policies/tests/allow-find-in-project.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowFindInProject from "../allow-find-in-project"; +const run = adaptHandler(allowFindInProject.action!, allowFindInProject.handler as any); + const PROJECT = "/home/user/project"; function find(path: string | undefined, projectRoot: string | null = PROJECT): ToolCall { @@ -17,61 +19,61 @@ function find(path: string | undefined, projectRoot: string | null = PROJECT): T describe("allow-find-in-project", () => { describe("allows find within project", () => { it("allows explicit path in project", async () => { - const result = await allowFindInProject.handler(find("/home/user/project/src")); + const result = await run(find("/home/user/project/src")); expect(result.verdict).toBe(ALLOW); }); it("allows project root as path", async () => { - const result = await allowFindInProject.handler(find("/home/user/project")); + const result = await run(find("/home/user/project")); expect(result.verdict).toBe(ALLOW); }); it("allows nested path", async () => { - const result = await allowFindInProject.handler(find("/home/user/project/a/b/c")); + const result = await run(find("/home/user/project/a/b/c")); expect(result.verdict).toBe(ALLOW); }); it("allows when no path specified (defaults to cwd)", async () => { - const result = await allowFindInProject.handler(find(undefined)); + const result = await run(find(undefined)); expect(result.verdict).toBe(ALLOW); }); it("allows relative path within project", async () => { - const result = await allowFindInProject.handler(find("src/utils")); + const result = await run(find("src/utils")); expect(result.verdict).toBe(ALLOW); }); it("allows relative path with dot prefix", async () => { - const result = await allowFindInProject.handler(find("./toolgate/policies")); + const result = await run(find("./toolgate/policies")); expect(result.verdict).toBe(ALLOW); }); }); describe("rejects find outside project", () => { it("rejects path outside project", async () => { - const result = await allowFindInProject.handler(find("/etc")); + const result = await run(find("/etc")); expect(result.verdict).toBe(NEXT); }); it("rejects sibling directory", async () => { - const result = await allowFindInProject.handler(find("/home/user/other-project")); + const result = await run(find("/home/user/other-project")); expect(result.verdict).toBe(NEXT); }); it("rejects prefix trick", async () => { - const result = await allowFindInProject.handler(find("/home/user/project-evil/src")); + const result = await run(find("/home/user/project-evil/src")); expect(result.verdict).toBe(NEXT); }); }); describe("passes through when no project root", () => { it("with explicit path", async () => { - const result = await allowFindInProject.handler(find("/home/user/project/src", null)); + const result = await run(find("/home/user/project/src", null)); expect(result.verdict).toBe(NEXT); }); it("with no path", async () => { - const result = await allowFindInProject.handler(find(undefined, null)); + const result = await run(find(undefined, null)); expect(result.verdict).toBe(NEXT); }); }); @@ -83,7 +85,7 @@ describe("allow-find-in-project", () => { args: { pattern: "*.ts" }, context: { cwd: "/tmp", env: {}, projectRoot: PROJECT }, }; - const result = await allowFindInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); @@ -94,7 +96,7 @@ describe("allow-find-in-project", () => { args: { command: "find ." }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowFindInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-gh-read-only.test.ts b/policies/tests/allow-gh-read-only.test.ts index 46baa0d..37ecf0a 100644 --- a/policies/tests/allow-gh-read-only.test.ts +++ b/policies/tests/allow-gh-read-only.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGhReadOnly from "../allow-gh-read-only"; +const run = adaptHandler(allowGhReadOnly.action!, allowGhReadOnly.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -34,7 +36,7 @@ describe("allow-gh-read-only", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGhReadOnly.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -53,7 +55,7 @@ describe("allow-gh-read-only", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGhReadOnly.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -74,7 +76,7 @@ describe("allow-gh-read-only", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGhReadOnly.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -92,7 +94,7 @@ describe("allow-gh-read-only", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGhReadOnly.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -107,14 +109,14 @@ describe("allow-gh-read-only", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowGhReadOnly.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } }); it("passes through non-gh commands", async () => { - const result = await allowGhReadOnly.handler(bash("ls -la")); + const result = await run(bash("ls -la")); expect(result.verdict).toBe(NEXT); }); @@ -124,7 +126,7 @@ describe("allow-gh-read-only", () => { args: { file_path: "/tmp/test" }, context: { cwd: "/home/user/project", env: {}, projectRoot: "/home/user/project" }, }; - const result = await allowGhReadOnly.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-git-add.test.ts b/policies/tests/allow-git-add.test.ts index 327e7fa..5961271 100644 --- a/policies/tests/allow-git-add.test.ts +++ b/policies/tests/allow-git-add.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGitAdd from "../allow-git-add"; +const run = adaptHandler(allowGitAdd.action!, allowGitAdd.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -32,7 +34,7 @@ describe("allow-git-add", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitAdd.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -49,7 +51,7 @@ describe("allow-git-add", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitAdd.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -64,7 +66,7 @@ describe("allow-git-add", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitAdd.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -80,7 +82,7 @@ describe("allow-git-add", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitAdd.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -96,7 +98,7 @@ describe("allow-git-add", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitAdd.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -109,7 +111,7 @@ describe("allow-git-add", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitAdd.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -125,7 +127,7 @@ describe("allow-git-add", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowGitAdd.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -143,7 +145,7 @@ describe("allow-git-add", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitAdd.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -151,12 +153,12 @@ describe("allow-git-add", () => { describe("passes through non-Bash tools", () => { it("passes through Read tool", async () => { - const result = await allowGitAdd.handler(otherTool("Read")); + const result = await run(otherTool("Read")); expect(result.verdict).toBe(NEXT); }); it("passes through Write tool", async () => { - const result = await allowGitAdd.handler(otherTool("Write")); + const result = await run(otherTool("Write")); expect(result.verdict).toBe(NEXT); }); }); @@ -168,7 +170,7 @@ describe("allow-git-add", () => { args: { command: 123 }, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await allowGitAdd.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); @@ -178,7 +180,7 @@ describe("allow-git-add", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await allowGitAdd.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-git-branch.test.ts b/policies/tests/allow-git-branch.test.ts index 17fad96..3dbf933 100644 --- a/policies/tests/allow-git-branch.test.ts +++ b/policies/tests/allow-git-branch.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowReadOnlyGitBranch from "../allow-git-branch"; +const run = adaptHandler(allowReadOnlyGitBranch.action!, allowReadOnlyGitBranch.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -39,7 +41,7 @@ describe("allow-git-branch", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowReadOnlyGitBranch.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -54,7 +56,7 @@ describe("allow-git-branch", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowReadOnlyGitBranch.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -83,7 +85,7 @@ describe("allow-git-branch", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowReadOnlyGitBranch.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -98,7 +100,7 @@ describe("allow-git-branch", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowReadOnlyGitBranch.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -113,7 +115,7 @@ describe("allow-git-branch", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowReadOnlyGitBranch.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } diff --git a/policies/tests/allow-git-checkout-b.test.ts b/policies/tests/allow-git-checkout-b.test.ts index df8da9e..b91f3b2 100644 --- a/policies/tests/allow-git-checkout-b.test.ts +++ b/policies/tests/allow-git-checkout-b.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGitCheckoutB from "../allow-git-checkout-b"; +const run = adaptHandler(allowGitCheckoutB.action!, allowGitCheckoutB.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -21,7 +23,7 @@ describe("allow-git-checkout-b", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitCheckoutB.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -42,7 +44,7 @@ describe("allow-git-checkout-b", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitCheckoutB.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -57,7 +59,7 @@ describe("allow-git-checkout-b", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowGitCheckoutB.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -72,7 +74,7 @@ describe("allow-git-checkout-b", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitCheckoutB.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } diff --git a/policies/tests/allow-git-commit.test.ts b/policies/tests/allow-git-commit.test.ts index 54e73e5..c6c4af7 100644 --- a/policies/tests/allow-git-commit.test.ts +++ b/policies/tests/allow-git-commit.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGitCommit from "../allow-git-commit"; +const run = adaptHandler(allowGitCommit.action!, allowGitCommit.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -26,7 +28,7 @@ describe("allow-git-commit", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitCommit.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -45,7 +47,7 @@ describe("allow-git-commit", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowGitCommit.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -62,7 +64,7 @@ describe("allow-git-commit", () => { for (const cmd of rejected) { it(`passes through: ${cmd}`, async () => { - const result = await allowGitCommit.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -74,7 +76,7 @@ describe("allow-git-commit", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await allowGitCommit.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-git-diff.test.ts b/policies/tests/allow-git-diff.test.ts index 7131bb8..9457925 100644 --- a/policies/tests/allow-git-diff.test.ts +++ b/policies/tests/allow-git-diff.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGitDiff from "../allow-git-diff"; +const run = adaptHandler(allowGitDiff.action!, allowGitDiff.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -25,7 +27,7 @@ describe("allow-git-diff", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitDiff.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -40,7 +42,7 @@ describe("allow-git-diff", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowGitDiff.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -55,7 +57,7 @@ describe("allow-git-diff", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitDiff.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } diff --git a/policies/tests/allow-git-local-repo.test.ts b/policies/tests/allow-git-local-repo.test.ts index ab5ab95..f0819fc 100644 --- a/policies/tests/allow-git-local-repo.test.ts +++ b/policies/tests/allow-git-local-repo.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, mock, beforeEach } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; // Mock Bun.spawn to simulate `git remote` output let spawnOutput = ""; @@ -17,7 +17,7 @@ describe("allow-git-local-repo", () => { // We need to re-import the module for each describe block to reset the cache. // Instead, we'll test with different projectRoots to avoid cache hits. - let allowGitLocalRepo: typeof import("../allow-git-local-repo").default; + let run: ReturnType; let callCount = 0; beforeEach(async () => { @@ -26,7 +26,8 @@ describe("allow-git-local-repo", () => { const modulePath = require.resolve("../allow-git-local-repo"); delete require.cache[modulePath]; const mod = await import("../allow-git-local-repo"); - allowGitLocalRepo = mod.default; + const allowGitLocalRepo = mod.default; + run = adaptHandler(allowGitLocalRepo.action!, allowGitLocalRepo.handler as any); // Default: simulate local repo (no remotes) spawnOutput = ""; @@ -70,7 +71,7 @@ describe("allow-git-local-repo", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { const root = `/tmp/local-${callCount++}`; - const result = await allowGitLocalRepo.handler(bash(cmd, root)); + const result = await run(bash(cmd, root)); expect(result.verdict).toBe(ALLOW); }); } @@ -95,7 +96,7 @@ describe("allow-git-local-repo", () => { for (const cmd of blocked) { it(`blocks: ${cmd}`, async () => { const root = `/tmp/local-blocked-${callCount++}`; - const result = await allowGitLocalRepo.handler(bash(cmd, root)); + const result = await run(bash(cmd, root)); expect(result.verdict).toBe(NEXT); }); } @@ -104,7 +105,7 @@ describe("allow-git-local-repo", () => { describe("allows safe restore variants", () => { it("allows git restore --staged", async () => { const root = `/tmp/local-staged-${callCount++}`; - const result = await allowGitLocalRepo.handler(bash("git restore --staged src/file.ts", root)); + const result = await run(bash("git restore --staged src/file.ts", root)); expect(result.verdict).toBe(ALLOW); }); }); @@ -113,14 +114,14 @@ describe("allow-git-local-repo", () => { it("passes through when repo has origin", async () => { spawnOutput = "origin\n"; const root = `/tmp/remote-${callCount++}`; - const result = await allowGitLocalRepo.handler(bash("git commit -m 'test'", root)); + const result = await run(bash("git commit -m 'test'", root)); expect(result.verdict).toBe(NEXT); }); it("passes through when repo has multiple remotes", async () => { spawnOutput = "origin\nupstream\n"; const root = `/tmp/multi-remote-${callCount++}`; - const result = await allowGitLocalRepo.handler(bash("git push", root)); + const result = await run(bash("git push", root)); expect(result.verdict).toBe(NEXT); }); }); @@ -132,13 +133,13 @@ describe("allow-git-local-repo", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: "/tmp" }, }; - const result = await allowGitLocalRepo.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); it("passes through non-git bash commands", async () => { const root = `/tmp/local-nongit-${callCount++}`; - const result = await allowGitLocalRepo.handler(bash("ls -la", root)); + const result = await run(bash("ls -la", root)); expect(result.verdict).toBe(NEXT); }); }); @@ -150,7 +151,7 @@ describe("allow-git-local-repo", () => { args: { command: "git status" }, context: { cwd: "/tmp", env: {}, projectRoot: null as any }, }; - const result = await allowGitLocalRepo.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-git-log.test.ts b/policies/tests/allow-git-log.test.ts index 4c362a4..57adc40 100644 --- a/policies/tests/allow-git-log.test.ts +++ b/policies/tests/allow-git-log.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGitLog from "../allow-git-log"; +const run = adaptHandler(allowGitLog.action!, allowGitLog.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -26,7 +28,7 @@ describe("allow-git-log", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitLog.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -43,7 +45,7 @@ describe("allow-git-log", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitLog.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -57,7 +59,7 @@ describe("allow-git-log", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitLog.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -71,7 +73,7 @@ describe("allow-git-log", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowGitLog.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -90,7 +92,7 @@ describe("allow-git-log", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitLog.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -105,7 +107,7 @@ describe("allow-git-log", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitLog.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -117,7 +119,7 @@ describe("allow-git-log", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await allowGitLog.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-git-rev-parse.test.ts b/policies/tests/allow-git-rev-parse.test.ts index 53a08f5..da5f9eb 100644 --- a/policies/tests/allow-git-rev-parse.test.ts +++ b/policies/tests/allow-git-rev-parse.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGitRevParse from "../allow-git-rev-parse"; +const run = adaptHandler(allowGitRevParse.action!, allowGitRevParse.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -24,7 +26,7 @@ describe("allow-git-rev-parse", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitRevParse.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -41,7 +43,7 @@ describe("allow-git-rev-parse", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitRevParse.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -55,7 +57,7 @@ describe("allow-git-rev-parse", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitRevParse.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -69,7 +71,7 @@ describe("allow-git-rev-parse", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowGitRevParse.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -84,7 +86,7 @@ describe("allow-git-rev-parse", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitRevParse.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -96,7 +98,7 @@ describe("allow-git-rev-parse", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await allowGitRevParse.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-git-status.test.ts b/policies/tests/allow-git-status.test.ts index 67ab54f..340f35b 100644 --- a/policies/tests/allow-git-status.test.ts +++ b/policies/tests/allow-git-status.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGitStatus from "../allow-git-status"; +const run = adaptHandler(allowGitStatus.action!, allowGitStatus.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -23,7 +25,7 @@ describe("allow-git-status", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitStatus.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -38,7 +40,7 @@ describe("allow-git-status", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowGitStatus.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -53,7 +55,7 @@ describe("allow-git-status", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowGitStatus.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } diff --git a/policies/tests/allow-git-worktree.test.ts b/policies/tests/allow-git-worktree.test.ts index e7179d1..9003936 100644 --- a/policies/tests/allow-git-worktree.test.ts +++ b/policies/tests/allow-git-worktree.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGitWorktree from "../allow-git-worktree"; +const run = adaptHandler(allowGitWorktree.action!, allowGitWorktree.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string): ToolCall { @@ -33,7 +35,7 @@ describe("allow-git-worktree", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowGitWorktree.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -41,14 +43,14 @@ describe("allow-git-worktree", () => { describe("real-world patterns from logs", () => { it("allows: git worktree add with branch and remote tracking", async () => { - const result = await allowGitWorktree.handler( + const result = await run( bash("git worktree add .claude/worktrees/remove-poc -b remove-poc origin/develop"), ); expect(result.verdict).toBe(ALLOW); }); it("allows: git worktree remove", async () => { - const result = await allowGitWorktree.handler( + const result = await run( bash("git worktree remove .claude/worktrees/remove-poc"), ); expect(result.verdict).toBe(ALLOW); @@ -65,7 +67,7 @@ describe("allow-git-worktree", () => { for (const cmd of passThrough) { it(`next: ${cmd}`, async () => { - const result = await allowGitWorktree.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -77,7 +79,7 @@ describe("allow-git-worktree", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowGitWorktree.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-grep-in-project.test.ts b/policies/tests/allow-grep-in-project.test.ts index e2801d4..e0fdd1e 100644 --- a/policies/tests/allow-grep-in-project.test.ts +++ b/policies/tests/allow-grep-in-project.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowGrepInProject from "../allow-grep-in-project"; +const run = adaptHandler(allowGrepInProject.action!, allowGrepInProject.handler as any); + const PROJECT = "/home/user/project"; function grep(path: string | undefined, projectRoot: string | null = PROJECT): ToolCall { @@ -17,61 +19,61 @@ function grep(path: string | undefined, projectRoot: string | null = PROJECT): T describe("allow-grep-in-project", () => { describe("allows grep within project", () => { it("allows explicit path in project", async () => { - const result = await allowGrepInProject.handler(grep("/home/user/project/src")); + const result = await run(grep("/home/user/project/src")); expect(result.verdict).toBe(ALLOW); }); it("allows project root as path", async () => { - const result = await allowGrepInProject.handler(grep("/home/user/project")); + const result = await run(grep("/home/user/project")); expect(result.verdict).toBe(ALLOW); }); it("allows nested path", async () => { - const result = await allowGrepInProject.handler(grep("/home/user/project/a/b/c")); + const result = await run(grep("/home/user/project/a/b/c")); expect(result.verdict).toBe(ALLOW); }); it("allows when no path specified (defaults to cwd)", async () => { - const result = await allowGrepInProject.handler(grep(undefined)); + const result = await run(grep(undefined)); expect(result.verdict).toBe(ALLOW); }); it("allows relative path within project", async () => { - const result = await allowGrepInProject.handler(grep("src/utils")); + const result = await run(grep("src/utils")); expect(result.verdict).toBe(ALLOW); }); it("allows relative path with dot prefix", async () => { - const result = await allowGrepInProject.handler(grep("./toolgate/policies")); + const result = await run(grep("./toolgate/policies")); expect(result.verdict).toBe(ALLOW); }); }); describe("rejects grep outside project", () => { it("rejects path outside project", async () => { - const result = await allowGrepInProject.handler(grep("/etc")); + const result = await run(grep("/etc")); expect(result.verdict).toBe(NEXT); }); it("rejects sibling directory", async () => { - const result = await allowGrepInProject.handler(grep("/home/user/other-project")); + const result = await run(grep("/home/user/other-project")); expect(result.verdict).toBe(NEXT); }); it("rejects prefix trick", async () => { - const result = await allowGrepInProject.handler(grep("/home/user/project-evil/src")); + const result = await run(grep("/home/user/project-evil/src")); expect(result.verdict).toBe(NEXT); }); }); describe("passes through when no project root", () => { it("with explicit path", async () => { - const result = await allowGrepInProject.handler(grep("/home/user/project/src", null)); + const result = await run(grep("/home/user/project/src", null)); expect(result.verdict).toBe(NEXT); }); it("with no path", async () => { - const result = await allowGrepInProject.handler(grep(undefined, null)); + const result = await run(grep(undefined, null)); expect(result.verdict).toBe(NEXT); }); }); @@ -83,7 +85,7 @@ describe("allow-grep-in-project", () => { args: { pattern: "foo" }, context: { cwd: "/tmp", env: {}, projectRoot: PROJECT }, }; - const result = await allowGrepInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); @@ -95,7 +97,7 @@ describe("allow-grep-in-project", () => { args: { pattern: "foo", path: "/shared/lib/utils.ts" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT, additionalDirs: ["/shared/lib"] }, }; - const result = await allowGrepInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(ALLOW); }); @@ -105,7 +107,7 @@ describe("allow-grep-in-project", () => { args: { pattern: "foo", path: "/shared/lib" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT, additionalDirs: ["/shared/lib"] }, }; - const result = await allowGrepInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(ALLOW); }); @@ -115,7 +117,7 @@ describe("allow-grep-in-project", () => { args: { pattern: "foo", path: "/secret/data" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT, additionalDirs: ["/shared/lib"] }, }; - const result = await allowGrepInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); @@ -125,7 +127,7 @@ describe("allow-grep-in-project", () => { args: { pattern: "foo" }, context: { cwd: "/shared/lib/src", env: {}, projectRoot: PROJECT, additionalDirs: ["/shared/lib"] }, }; - const result = await allowGrepInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(ALLOW); }); }); @@ -136,7 +138,7 @@ describe("allow-grep-in-project", () => { args: { command: "grep foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowGrepInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-ls-in-project.test.ts b/policies/tests/allow-ls.test.ts similarity index 57% rename from policies/tests/allow-ls-in-project.test.ts rename to policies/tests/allow-ls.test.ts index 9757622..2443f0f 100644 --- a/policies/tests/allow-ls-in-project.test.ts +++ b/policies/tests/allow-ls.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; -import allowLsInProject from "../allow-ls-in-project"; +import { homedir } from "node:os"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowLs from "../allow-ls"; +const run = adaptHandler(allowLs.action!, allowLs.handler as any); + +const HOME = homedir(); const PROJECT = "/home/user/project"; function bash(command: string, cwd = PROJECT, projectRoot: string | null = PROJECT): ToolCall { @@ -12,8 +16,8 @@ function bash(command: string, cwd = PROJECT, projectRoot: string | null = PROJE }; } -describe("allow-ls-in-project", () => { - describe("allows ls within project", () => { +describe("allow-ls", () => { + describe("allows ls against non-dot paths", () => { const allowed = [ "ls", "ls -la", @@ -22,44 +26,64 @@ describe("allow-ls-in-project", () => { "ls ./src", "ls src/components", "ls .", + "ls ..", "ls -la src", `ls ${PROJECT}/src`, `ls ${PROJECT}`, + "ls /etc", + "ls /home/user/other-project", + "ls /tmp", + "ls /Applications", + `ls ${PROJECT}-evil`, + `ls ${HOME}/Dev`, ]; for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowLsInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } }); - describe("rejects ls outside project", () => { + describe("rejects ls against dot-prefixed segments", () => { const rejected = [ - "ls /etc", - "ls /home/user/other-project", - "ls /tmp", - `ls ${PROJECT}-evil`, + "ls .git", + "ls .env", + "ls -la .ssh", + "ls src/.cache", + "ls ./.next", + "ls ~/.ssh", + "ls ~/.aws/credentials.d", + `ls ${HOME}/.claude`, + `ls ${HOME}/.claude/plugins/marketplaces`, + "ls /home/user/.gnupg", + `ls ${PROJECT}/.git/refs`, + "ls -la src/.cache nested", ]; for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowLsInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } }); - describe("rejects bare ls when cwd is outside project", () => { - it("rejects ls in /tmp", async () => { - const result = await allowLsInProject.handler(bash("ls", "/tmp")); - expect(result.verdict).toBe(NEXT); + describe("allows ls regardless of cwd", () => { + it("allows ls in /tmp", async () => { + const result = await run(bash("ls", "/tmp")); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows ls -la in /tmp", async () => { + const result = await run(bash("ls -la", "/tmp")); + expect(result.verdict).toBe(ALLOW); }); - it("rejects ls -la in /tmp", async () => { - const result = await allowLsInProject.handler(bash("ls -la", "/tmp")); - expect(result.verdict).toBe(NEXT); + it("allows ls when there is no project root", async () => { + const result = await run(bash("ls", PROJECT, null)); + expect(result.verdict).toBe(ALLOW); }); }); @@ -72,7 +96,7 @@ describe("allow-ls-in-project", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowLsInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -86,11 +110,12 @@ describe("allow-ls-in-project", () => { "ls -la | grep foo | head -5", `ls ${PROJECT}/src | sort`, "ls -la | grep test | wc -l", + "ls /etc | grep host", ]; for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowLsInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -106,19 +131,14 @@ describe("allow-ls-in-project", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowLsInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } }); - it("passes through when no project root", async () => { - const result = await allowLsInProject.handler(bash("ls", PROJECT, null)); - expect(result.verdict).toBe(NEXT); - }); - it("passes through non-ls commands", async () => { - const result = await allowLsInProject.handler(bash("cat /etc/passwd")); + const result = await run(bash("cat /etc/passwd")); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-lsof.test.ts b/policies/tests/allow-lsof.test.ts new file mode 100644 index 0000000..9b432c4 --- /dev/null +++ b/policies/tests/allow-lsof.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowLsof from "../allow-lsof"; + +const run = adaptHandler(allowLsof.action!, allowLsof.handler as any); + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; +} + +describe("allow-lsof", () => { + describe("allows safe lsof invocations", () => { + const allowed = [ + "lsof", + "lsof -i", + "lsof -i :8080", + "lsof -i tcp:443", + "lsof -p 1234", + "lsof -u bryce", + "lsof -c node", + "lsof /var/log/system.log", + "lsof -nP -i", + "lsof -i :8080 | grep node", + "lsof | head -20", + "lsof -i | wc -l", + ]; + + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("rejects unsafe patterns", () => { + const rejected = [ + "lsof && rm -rf /tmp/x", + "lsof; curl evil.com", + "lsof $(whoami)", + "lsof `id`", + "lsof > /etc/passwd", + "lsof &", + "! lsof", + ]; + + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + it("passes through non-Bash tools", async () => { + const call: ToolCall = { + tool: "Read", + args: {}, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; + const result = await run(call); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through non-lsof bash commands", async () => { + const result = await run(bash("echo hello")); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/allow-magick-in-project.test.ts b/policies/tests/allow-magick-in-project.test.ts new file mode 100644 index 0000000..4ada0dc --- /dev/null +++ b/policies/tests/allow-magick-in-project.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowMagickInProject from "../allow-magick-in-project"; + +const run = adaptHandler(allowMagickInProject.action!, allowMagickInProject.handler as any); + +const PROJECT = "/home/user/project"; + +function bash(command: string, cwd = PROJECT, projectRoot: string | null = PROJECT): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd, env: {}, projectRoot }, + }; +} + +describe("allow-magick-in-project", () => { + describe("allows magick with safe flags and in-project paths", () => { + const allowed = [ + "magick -version", + "magick in.png out.png", + "magick in.png -resize 600x1200 out.webp", + "magick in.png -gravity center -crop 600x1200+0+0 +repage -quality 88 out.webp", + "magick in.png -threshold 50% out.png", + "magick in.png -negate out.png", + "magick in.png -fuzz 25% +opaque '#ff00ff' out.png", + "magick in.png -channel R -separate out.png", + "magick in.png -threshold 50% -connected-components 4 out.png", + `magick ${PROJECT}/in.png ${PROJECT}/out.png`, + "magick sub/dir/in.png sub/dir/out.webp", + ]; + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("allows info: / null: as output", () => { + const allowed = [ + "magick in.png -channel R -separate info:", + "magick in.png -threshold 50% -connected-components 4 null:", + "magick in.png info:", + "magick in.png null:", + ]; + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("rejects unsafe flags", () => { + const rejected = [ + "magick in.png -define delegate:bimodal=true out.png", + "magick in.png -define connected-components:verbose=true out.png", + "magick in.png -fill black +opaque red out.png", + "magick in.png -format '%[fx:mean]' info:", + "magick -script script.msl", + "magick in.png -process module out.png", + "magick in.png -resize 100x100 -write tmp.png -resize 50x50 final.png", + "magick in.png -set option:eval-expression yes out.png", + "magick in.png -debug All out.png", + "magick in.png -log '%t' out.png", + ]; + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rejects pseudo-format / scheme inputs", () => { + const rejected = [ + "magick https://evil.com/x.png out.png", + "magick http://evil.com/x.png out.png", + "magick ftp://evil.com/x.png out.png", + "magick mvg:in.mvg out.png", + "magick msl:script.msl out.png", + "magick text:secret.txt out.png", + "magick label:'hello' out.png", + "magick caption:'hello' out.png", + "magick pango:'hello' out.png", + "magick ephemeral:in.png out.png", + "magick inline:DEADBEEF out.png", + "magick pattern:checkerboard out.png", + "magick tile:in.png out.png", + "magick xc:red out.png", + "magick gradient:red-blue out.png", + ]; + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rejects @-prefixed positional", () => { + it("rejects @file.txt input", async () => { + const result = await run(bash("magick @list.txt out.png")); + expect(result.verdict).toBe(NEXT); + }); + }); + + describe("rejects outputs outside project", () => { + const rejected = [ + "magick in.png /tmp/out.png", + "magick in.png /etc/out.png", + "magick in.png ../sibling/out.png", + `magick in.png ${PROJECT}-evil/out.png`, + "magick in.png ~/Desktop/out.png", + ]; + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rejects inputs outside project", () => { + const rejected = [ + "magick /etc/passwd out.png", + "magick ../other/in.png out.png", + `magick ${PROJECT}-evil/in.png out.png`, + ]; + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rejects compound commands", () => { + const rejected = [ + "magick in.png out.png && rm -rf /", + "magick in.png out.png; echo pwned", + "magick in.png out.png | head", + ]; + for (const cmd of rejected) { + it(`rejects: ${JSON.stringify(cmd)}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rejects malformed invocations", () => { + it("rejects bare magick (no args)", async () => { + const result = await run(bash("magick")); + expect(result.verdict).toBe(NEXT); + }); + it("rejects magick with input but no output", async () => { + const result = await run(bash("magick in.png")); + expect(result.verdict).toBe(NEXT); + }); + it("rejects unknown flag", async () => { + const result = await run(bash("magick in.png -mystery-flag out.png")); + expect(result.verdict).toBe(NEXT); + }); + it("rejects one-arg flag missing its value", async () => { + const result = await run(bash("magick in.png -resize")); + expect(result.verdict).toBe(NEXT); + }); + }); + + it("passes through when no project root", async () => { + const result = await run(bash("magick in.png out.png", PROJECT, null)); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through non-magick commands", async () => { + const result = await run(bash("ls -la")); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/allow-mcp-context7.test.ts b/policies/tests/allow-mcp-context7.test.ts index 5d2602d..9720016 100644 --- a/policies/tests/allow-mcp-context7.test.ts +++ b/policies/tests/allow-mcp-context7.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowMcpContext7 from "../allow-mcp-context7"; +const run = adaptHandler(allowMcpContext7.action!, allowMcpContext7.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record = {}): ToolCall => ({ @@ -12,21 +14,21 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-mcp-context7", () => { it("allows mcp__context7__resolve-library-id", async () => { - const result = await allowMcpContext7.handler( + const result = await run( makeCall("mcp__context7__resolve-library-id", { query: "react hooks", libraryName: "react" }) ); expect(result.verdict).toBe(ALLOW); }); it("allows mcp__context7__query-docs", async () => { - const result = await allowMcpContext7.handler( + const result = await run( makeCall("mcp__context7__query-docs", { libraryId: "/vercel/next.js", query: "routing" }) ); expect(result.verdict).toBe(ALLOW); }); it("passes through non-Context7 tools", async () => { - const result = await allowMcpContext7.handler(makeCall("Bash", { command: "echo hello" })); + const result = await run(makeCall("Bash", { command: "echo hello" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-mcp-ide-diagnostics.test.ts b/policies/tests/allow-mcp-ide-diagnostics.test.ts index 0581731..b9a3aa7 100644 --- a/policies/tests/allow-mcp-ide-diagnostics.test.ts +++ b/policies/tests/allow-mcp-ide-diagnostics.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowMcpIdeDiagnostics from "../allow-mcp-ide-diagnostics"; +const run = adaptHandler(allowMcpIdeDiagnostics.action!, allowMcpIdeDiagnostics.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record = {}): ToolCall => ({ @@ -12,17 +14,17 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-mcp-ide-diagnostics", () => { it("allows mcp__ide__getDiagnostics", async () => { - const result = await allowMcpIdeDiagnostics.handler(makeCall("mcp__ide__getDiagnostics", { uri: "file:///test.ts" })); + const result = await run(makeCall("mcp__ide__getDiagnostics", { uri: "file:///test.ts" })); expect(result.verdict).toBe(ALLOW); }); it("allows mcp__ide__getDiagnostics without args", async () => { - const result = await allowMcpIdeDiagnostics.handler(makeCall("mcp__ide__getDiagnostics")); + const result = await run(makeCall("mcp__ide__getDiagnostics")); expect(result.verdict).toBe(ALLOW); }); it("passes through non-getDiagnostics tools", async () => { - const result = await allowMcpIdeDiagnostics.handler(makeCall("Bash", { command: "echo hello" })); + const result = await run(makeCall("Bash", { command: "echo hello" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-mcp-playwright.test.ts b/policies/tests/allow-mcp-playwright.test.ts index f7332ff..6f4fabb 100644 --- a/policies/tests/allow-mcp-playwright.test.ts +++ b/policies/tests/allow-mcp-playwright.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowMcpPlaywright from "../allow-mcp-playwright"; +const run = adaptHandler(allowMcpPlaywright.action!, allowMcpPlaywright.handler as any); + const makeCall = (tool: string, args: Record = {}): ToolCall => ({ tool, args, @@ -10,27 +12,27 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-mcp-playwright", () => { it("allows mcp__playwright__browser_navigate", async () => { - const result = await allowMcpPlaywright.handler(makeCall("mcp__playwright__browser_navigate", { url: "about:blank" })); + const result = await run(makeCall("mcp__playwright__browser_navigate", { url: "about:blank" })); expect(result.verdict).toBe(ALLOW); }); it("allows mcp__playwright__browser_snapshot", async () => { - const result = await allowMcpPlaywright.handler(makeCall("mcp__playwright__browser_snapshot")); + const result = await run(makeCall("mcp__playwright__browser_snapshot")); expect(result.verdict).toBe(ALLOW); }); it("allows mcp__playwright__browser_click", async () => { - const result = await allowMcpPlaywright.handler(makeCall("mcp__playwright__browser_click", { element: "Submit", ref: "e1" })); + const result = await run(makeCall("mcp__playwright__browser_click", { element: "Submit", ref: "e1" })); expect(result.verdict).toBe(ALLOW); }); it("passes through non-playwright tools", async () => { - const result = await allowMcpPlaywright.handler(makeCall("Bash", { command: "echo hi" })); + const result = await run(makeCall("Bash", { command: "echo hi" })); expect(result.verdict).toBe(NEXT); }); it("passes through other MCP tools", async () => { - const result = await allowMcpPlaywright.handler(makeCall("mcp__context7__query-docs", { query: "test" })); + const result = await run(makeCall("mcp__context7__query-docs", { query: "test" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-memory-crud.test.ts b/policies/tests/allow-memory-crud.test.ts new file mode 100644 index 0000000..ed925c9 --- /dev/null +++ b/policies/tests/allow-memory-crud.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "bun:test"; +import { homedir } from "node:os"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowMemoryCrud from "../allow-memory-crud"; + +const run = adaptHandler(allowMemoryCrud.action!, allowMemoryCrud.handler as any); + +const HOME = homedir(); +const PROJECT = "/home/user/project"; +const MEMORY = `${HOME}/.claude/projects/-home-user-project/memory`; + +function read(filePath: string): ToolCall { + return { + tool: "Read", + args: { file_path: filePath }, + context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, + }; +} + +function write(filePath: string): ToolCall { + return { + tool: "Write", + args: { file_path: filePath, content: "x" }, + context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, + }; +} + +function edit(filePath: string): ToolCall { + return { + tool: "Edit", + args: { file_path: filePath, old_string: "a", new_string: "b" }, + context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, + }; +} + +function bash(command: string, cwd = PROJECT): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd, env: {}, projectRoot: PROJECT }, + }; +} + +describe("allow-memory-crud", () => { + describe("file-tool CRUD on memory files", () => { + const cases = [ + ["Read", () => read(`${MEMORY}/MEMORY.md`)], + ["Read tilde", () => read(`~/.claude/projects/-home-user-project/memory/MEMORY.md`)], + ["Write", () => write(`${MEMORY}/new-memory.md`)], + ["Edit", () => edit(`${MEMORY}/MEMORY.md`)], + ] as const; + + for (const [label, mk] of cases) { + it(`allows ${label}`, async () => { + const result = await run(mk()); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("paths outside memory dir pass through", () => { + const cases = [ + ["sibling tool-results", () => read(`${HOME}/.claude/projects/-home-user-project/tool-results/foo.txt`)], + ["project file", () => write(`${PROJECT}/src/index.ts`)], + ["plain home file", () => read(`${HOME}/.bashrc`)], + ["fake memory prefix", () => write(`${HOME}/.claude/projects/x/memory-but-not-really/foo.md`)], + ["bare memory dir (no file)", () => read(`${MEMORY}`)], + ["non-CRUD tool", () => ({ + tool: "Grep", + args: { pattern: "foo", path: `${MEMORY}/MEMORY.md` }, + context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, + } as ToolCall)], + ] as const; + + for (const [label, mk] of cases) { + it(`passes through: ${label}`, async () => { + const result = await run(mk()); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rm via Bash", () => { + it("allows rm of a single memory file", async () => { + const result = await run(bash(`rm ${MEMORY}/old.md`)); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows rm with tilde path", async () => { + const result = await run(bash("rm ~/.claude/projects/-home-user-project/memory/stale.md")); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows rm of multiple memory files", async () => { + const result = await run(bash(`rm ${MEMORY}/a.md ${MEMORY}/b.md`)); + expect(result.verdict).toBe(ALLOW); + }); + + it("rejects rm -rf even on memory dir", async () => { + const result = await run(bash(`rm -rf ${MEMORY}`)); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects rm -f on memory file", async () => { + const result = await run(bash(`rm -f ${MEMORY}/x.md`)); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects rm on non-memory path", async () => { + const result = await run(bash(`rm ${PROJECT}/src/index.ts`)); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects rm on mixed memory and non-memory", async () => { + const result = await run(bash(`rm ${MEMORY}/a.md /etc/passwd`)); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects mv (not in rm allow)", async () => { + const result = await run(bash(`mv ${MEMORY}/a.md ${MEMORY}/b.md`)); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects rm chained", async () => { + const result = await run(bash(`rm ${MEMORY}/a.md && echo done`)); + expect(result.verdict).toBe(NEXT); + }); + }); + + describe("ls via Bash", () => { + it("allows ls of the memory dir", async () => { + const result = await run(bash(`ls ${MEMORY}`)); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows ls of the memory dir with trailing slash", async () => { + const result = await run(bash(`ls ${MEMORY}/`)); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows ls of a memory file", async () => { + const result = await run(bash(`ls ${MEMORY}/MEMORY.md`)); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows ls -la of the memory dir", async () => { + const result = await run(bash(`ls -la ${MEMORY}`)); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows ls with tilde path", async () => { + const result = await run(bash("ls ~/.claude/projects/-home-user-project/memory")); + expect(result.verdict).toBe(ALLOW); + }); + + it("passes through ls with no path arg", async () => { + const result = await run(bash("ls")); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects ls of non-memory path", async () => { + const result = await run(bash(`ls ${PROJECT}/src`)); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects ls of mixed memory and non-memory paths", async () => { + const result = await run(bash(`ls ${MEMORY} /etc`)); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects ls chained", async () => { + const result = await run(bash(`ls ${MEMORY} && echo done`)); + expect(result.verdict).toBe(NEXT); + }); + }); +}); diff --git a/policies/tests/allow-mkdir-in-project.test.ts b/policies/tests/allow-mkdir-in-project.test.ts index 49f8c90..f0f5989 100644 --- a/policies/tests/allow-mkdir-in-project.test.ts +++ b/policies/tests/allow-mkdir-in-project.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowMkdirInProject from "../allow-mkdir-in-project"; +const run = adaptHandler(allowMkdirInProject.action!, allowMkdirInProject.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string, cwd = PROJECT, projectRoot: string | null = PROJECT): ToolCall { @@ -25,7 +27,7 @@ describe("allow-mkdir-in-project", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowMkdirInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -42,7 +44,7 @@ describe("allow-mkdir-in-project", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowMkdirInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -50,12 +52,12 @@ describe("allow-mkdir-in-project", () => { describe("rejects mkdir with no path args", () => { it("rejects bare mkdir", async () => { - const result = await allowMkdirInProject.handler(bash("mkdir")); + const result = await run(bash("mkdir")); expect(result.verdict).toBe(NEXT); }); it("rejects mkdir -p (no path)", async () => { - const result = await allowMkdirInProject.handler(bash("mkdir -p")); + const result = await run(bash("mkdir -p")); expect(result.verdict).toBe(NEXT); }); }); @@ -68,19 +70,19 @@ describe("allow-mkdir-in-project", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowMkdirInProject.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } }); it("passes through when no project root", async () => { - const result = await allowMkdirInProject.handler(bash("mkdir src", PROJECT, null)); + const result = await run(bash("mkdir src", PROJECT, null)); expect(result.verdict).toBe(NEXT); }); it("passes through non-mkdir commands", async () => { - const result = await allowMkdirInProject.handler(bash("ls -la")); + const result = await run(bash("ls -la")); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-plan-in-project.test.ts b/policies/tests/allow-plan-in-project.test.ts index 720e8a2..6ec72f6 100644 --- a/policies/tests/allow-plan-in-project.test.ts +++ b/policies/tests/allow-plan-in-project.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowPlanInProject from "../allow-plan-in-project"; +const run = adaptHandler(allowPlanInProject.action!, allowPlanInProject.handler as any); + const PROJECT = "/home/user/project"; function plan(path: string | undefined, projectRoot: string | null = PROJECT): ToolCall { @@ -17,51 +19,51 @@ function plan(path: string | undefined, projectRoot: string | null = PROJECT): T describe("allow-plan-in-project", () => { describe("allows plan within project", () => { it("allows explicit path in project", async () => { - const result = await allowPlanInProject.handler(plan("/home/user/project/src")); + const result = await run(plan("/home/user/project/src")); expect(result.verdict).toBe(ALLOW); }); it("allows project root as path", async () => { - const result = await allowPlanInProject.handler(plan("/home/user/project")); + const result = await run(plan("/home/user/project")); expect(result.verdict).toBe(ALLOW); }); it("allows nested path", async () => { - const result = await allowPlanInProject.handler(plan("/home/user/project/a/b/c")); + const result = await run(plan("/home/user/project/a/b/c")); expect(result.verdict).toBe(ALLOW); }); it("allows when no path specified (defaults to cwd)", async () => { - const result = await allowPlanInProject.handler(plan(undefined)); + const result = await run(plan(undefined)); expect(result.verdict).toBe(ALLOW); }); }); describe("rejects plan outside project", () => { it("rejects path outside project", async () => { - const result = await allowPlanInProject.handler(plan("/etc")); + const result = await run(plan("/etc")); expect(result.verdict).toBe(NEXT); }); it("rejects sibling directory", async () => { - const result = await allowPlanInProject.handler(plan("/home/user/other-project")); + const result = await run(plan("/home/user/other-project")); expect(result.verdict).toBe(NEXT); }); it("rejects prefix trick", async () => { - const result = await allowPlanInProject.handler(plan("/home/user/project-evil/src")); + const result = await run(plan("/home/user/project-evil/src")); expect(result.verdict).toBe(NEXT); }); }); describe("passes through when no project root", () => { it("with explicit path", async () => { - const result = await allowPlanInProject.handler(plan("/home/user/project/src", null)); + const result = await run(plan("/home/user/project/src", null)); expect(result.verdict).toBe(NEXT); }); it("with no path", async () => { - const result = await allowPlanInProject.handler(plan(undefined, null)); + const result = await run(plan(undefined, null)); expect(result.verdict).toBe(NEXT); }); }); @@ -73,7 +75,7 @@ describe("allow-plan-in-project", () => { args: { task: "implement feature" }, context: { cwd: "/tmp", env: {}, projectRoot: PROJECT }, }; - const result = await allowPlanInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); @@ -84,7 +86,7 @@ describe("allow-plan-in-project", () => { args: { command: "echo plan" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowPlanInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-plan-mode.test.ts b/policies/tests/allow-plan-mode.test.ts index 92c2599..8fff0e8 100644 --- a/policies/tests/allow-plan-mode.test.ts +++ b/policies/tests/allow-plan-mode.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowPlanMode from "../allow-plan-mode"; +const run = adaptHandler(allowPlanMode.action!, allowPlanMode.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record = {}): ToolCall => ({ @@ -13,18 +15,18 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-plan-mode", () => { for (const tool of ["EnterPlanMode", "ExitPlanMode"]) { it(`allows ${tool}`, async () => { - const result = await allowPlanMode.handler(makeCall(tool)); + const result = await run(makeCall(tool)); expect(result.verdict).toBe(ALLOW); }); } it("passes through non-plan-mode tools", async () => { - const result = await allowPlanMode.handler(makeCall("Bash", { command: "echo hello" })); + const result = await run(makeCall("Bash", { command: "echo hello" })); expect(result.verdict).toBe(NEXT); }); it("passes through other tools", async () => { - const result = await allowPlanMode.handler(makeCall("Read", { file_path: "/foo" })); + const result = await run(makeCall("Read", { file_path: "/foo" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-pure-and-chains.test.ts b/policies/tests/allow-pure-and-chains.test.ts index 16b4b0f..38a96d3 100644 --- a/policies/tests/allow-pure-and-chains.test.ts +++ b/policies/tests/allow-pure-and-chains.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowPureAndChains from "../allow-pure-and-chains"; +const run = adaptHandler(allowPureAndChains.action!, allowPureAndChains.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string): ToolCall { @@ -34,7 +36,7 @@ describe("allow-pure-and-chains", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowPureAndChains.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -48,7 +50,7 @@ describe("allow-pure-and-chains", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowPureAndChains.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -78,7 +80,7 @@ describe("allow-pure-and-chains", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowPureAndChains.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -106,7 +108,7 @@ describe("allow-pure-and-chains", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowPureAndChains.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -118,12 +120,12 @@ describe("allow-pure-and-chains", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowPureAndChains.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); it("passes through single impure command", async () => { - const result = await allowPureAndChains.handler(bash("rm -rf /")); + const result = await run(bash("rm -rf /")); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-read-in-project.test.ts b/policies/tests/allow-read-in-project.test.ts index 5c0dedf..1c541f1 100644 --- a/policies/tests/allow-read-in-project.test.ts +++ b/policies/tests/allow-read-in-project.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it, beforeAll, afterAll } from "bun:test"; import { mkdirSync, symlinkSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowReadInProject from "../allow-read-in-project"; +const run = adaptHandler(allowReadInProject.action!, allowReadInProject.handler as any); + const PROJECT = "/home/user/project"; function read(filePath: string): ToolCall { @@ -17,41 +19,41 @@ function read(filePath: string): ToolCall { describe("allow-read-in-project", () => { it("allows reading a file in the project", async () => { - const result = await allowReadInProject.handler( + const result = await run( read(`${PROJECT}/src/index.ts`), ); expect(result.verdict).toBe(ALLOW); }); it("allows reading the project root itself", async () => { - const result = await allowReadInProject.handler(read(PROJECT)); + const result = await run(read(PROJECT)); expect(result.verdict).toBe(ALLOW); }); it("allows relative paths within the project", async () => { - const result = await allowReadInProject.handler( + const result = await run( read("docs/tickets/promotheus-tickets.md"), ); expect(result.verdict).toBe(ALLOW); }); it("allows relative paths with dot prefix", async () => { - const result = await allowReadInProject.handler(read("./src/index.ts")); + const result = await run(read("./src/index.ts")); expect(result.verdict).toBe(ALLOW); }); it("does not allow relative paths that escape the project", async () => { - const result = await allowReadInProject.handler(read("../../etc/passwd")); + const result = await run(read("../../etc/passwd")); expect(result.verdict).toBe(NEXT); }); it("does not allow reading outside the project", async () => { - const result = await allowReadInProject.handler(read("/etc/passwd")); + const result = await run(read("/etc/passwd")); expect(result.verdict).toBe(NEXT); }); it("does not allow reading home directory files", async () => { - const result = await allowReadInProject.handler( + const result = await run( read("~/.ssh/id_rsa"), ); expect(result.verdict).toBe(NEXT); @@ -63,7 +65,7 @@ describe("allow-read-in-project", () => { args: { command: "cat file.txt" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowReadInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); @@ -94,21 +96,21 @@ describe("allow-read-in-project", () => { } it("allows reading a real file in the project", async () => { - const result = await allowReadInProject.handler( + const result = await run( readReal(join(projectDir, "docs/real.md")), ); expect(result.verdict).toBe(ALLOW); }); it("blocks symlink that escapes the project", async () => { - const result = await allowReadInProject.handler( + const result = await run( readReal(join(projectDir, "docs/escape/secret.txt")), ); expect(result.verdict).toBe(NEXT); }); it("blocks relative path through symlink escape", async () => { - const result = await allowReadInProject.handler( + const result = await run( readReal("docs/escape/secret.txt"), ); expect(result.verdict).toBe(NEXT); diff --git a/policies/tests/allow-read-plugin-cache.test.ts b/policies/tests/allow-read-plugin-cache.test.ts index 25b2fce..25d73b7 100644 --- a/policies/tests/allow-read-plugin-cache.test.ts +++ b/policies/tests/allow-read-plugin-cache.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "bun:test"; import { homedir } from "os"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowReadPluginCache from "../allow-read-plugin-cache"; +const run = adaptHandler(allowReadPluginCache.action!, allowReadPluginCache.handler as any); + const PROJECT = "/home/user/project"; const CACHE = `${homedir()}/.claude/plugins/cache`; @@ -16,40 +18,40 @@ function read(filePath: string): ToolCall { describe("allow-read-plugin-cache", () => { it("allows reading a file in the plugin cache", async () => { - const result = await allowReadPluginCache.handler( + const result = await run( read(`${CACHE}/claude-plugins-official/manifest.json`), ); expect(result.verdict).toBe(ALLOW); }); it("allows reading the cache directory itself", async () => { - const result = await allowReadPluginCache.handler(read(CACHE)); + const result = await run(read(CACHE)); expect(result.verdict).toBe(ALLOW); }); it("allows reading nested subdirectories", async () => { - const result = await allowReadPluginCache.handler( + const result = await run( read(`${CACHE}/superpowers-marketplace/some/deep/file.ts`), ); expect(result.verdict).toBe(ALLOW); }); it("allows tilde paths", async () => { - const result = await allowReadPluginCache.handler( + const result = await run( read("~/.claude/plugins/cache/plugin/file.json"), ); expect(result.verdict).toBe(ALLOW); }); it("does not allow reading outside the cache", async () => { - const result = await allowReadPluginCache.handler( + const result = await run( read(`${homedir()}/.claude/plugins/installed_plugins.json`), ); expect(result.verdict).toBe(NEXT); }); it("does not allow reading other .claude directories", async () => { - const result = await allowReadPluginCache.handler( + const result = await run( read(`${homedir()}/.claude/settings.json`), ); expect(result.verdict).toBe(NEXT); @@ -61,7 +63,7 @@ describe("allow-read-plugin-cache", () => { args: { command: "cat ~/.claude/plugins/cache/file" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowReadPluginCache.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-read-tool-results.test.ts b/policies/tests/allow-read-tool-results.test.ts index 2364737..eaaad42 100644 --- a/policies/tests/allow-read-tool-results.test.ts +++ b/policies/tests/allow-read-tool-results.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "bun:test"; import { homedir } from "os"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowReadToolResults from "../allow-read-tool-results"; +const run = adaptHandler(allowReadToolResults.action!, allowReadToolResults.handler as any); + const PROJECT = "/home/user/project"; const PROJECTS_DIR = `${homedir()}/.claude/projects`; const SESSION_RESULTS = `${PROJECTS_DIR}/-Users-user-Dev-myapp/abc123-def456/tool-results`; @@ -17,35 +19,35 @@ function read(filePath: string): ToolCall { describe("allow-read-tool-results", () => { it("allows reading a file in tool-results", async () => { - const result = await allowReadToolResults.handler( + const result = await run( read(`${SESSION_RESULTS}/bmxf3c4ew.txt`), ); expect(result.verdict).toBe(ALLOW); }); it("allows tilde paths to tool-results", async () => { - const result = await allowReadToolResults.handler( + const result = await run( read("~/.claude/projects/-Users-user-Dev-myapp/abc123/tool-results/file.txt"), ); expect(result.verdict).toBe(ALLOW); }); it("does not allow reading session root (not tool-results)", async () => { - const result = await allowReadToolResults.handler( + const result = await run( read(`${PROJECTS_DIR}/-Users-user-Dev-myapp/abc123/transcript.jsonl`), ); expect(result.verdict).toBe(NEXT); }); it("does not allow reading the projects dir itself", async () => { - const result = await allowReadToolResults.handler( + const result = await run( read(PROJECTS_DIR), ); expect(result.verdict).toBe(NEXT); }); it("does not allow reading other .claude directories", async () => { - const result = await allowReadToolResults.handler( + const result = await run( read(`${homedir()}/.claude/settings.json`), ); expect(result.verdict).toBe(NEXT); @@ -57,7 +59,7 @@ describe("allow-read-tool-results", () => { args: { command: "cat ~/.claude/projects/foo/bar/tool-results/x.txt" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowReadToolResults.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-rm-project-tmp.test.ts b/policies/tests/allow-rm-project-tmp.test.ts index 50d8a01..a5ecf34 100644 --- a/policies/tests/allow-rm-project-tmp.test.ts +++ b/policies/tests/allow-rm-project-tmp.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowRmProjectTmp from "../allow-rm-project-tmp"; +const run = adaptHandler(allowRmProjectTmp.action!, allowRmProjectTmp.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string, cwd = PROJECT): ToolCall { @@ -22,14 +24,14 @@ describe("allow-rm-project-tmp", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowRmProjectTmp.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } }); it("allows rm with absolute path in project tmp/", async () => { - const result = await allowRmProjectTmp.handler( + const result = await run( bash("rm /home/user/project/tmp/file.txt"), ); expect(result.verdict).toBe(ALLOW); @@ -45,7 +47,7 @@ describe("allow-rm-project-tmp", () => { for (const cmd of flagged) { it(`requires approval: ${cmd}`, async () => { - const result = await allowRmProjectTmp.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -63,31 +65,31 @@ describe("allow-rm-project-tmp", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowRmProjectTmp.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } }); it("rejects rm of the tmp/ directory itself", async () => { - const result = await allowRmProjectTmp.handler(bash("rm -rf tmp")); + const result = await run(bash("rm -rf tmp")); expect(result.verdict).toBe(NEXT); }); it("rejects rm with no file arguments", async () => { - const result = await allowRmProjectTmp.handler(bash("rm -f")); + const result = await run(bash("rm -f")); expect(result.verdict).toBe(NEXT); }); it("rejects if any path is outside tmp/", async () => { - const result = await allowRmProjectTmp.handler( + const result = await run( bash("rm tmp/ok.txt src/bad.ts"), ); expect(result.verdict).toBe(NEXT); }); it("ignores non-rm commands", async () => { - const result = await allowRmProjectTmp.handler(bash("ls tmp/")); + const result = await run(bash("ls tmp/")); expect(result.verdict).toBe(NEXT); }); @@ -97,7 +99,7 @@ describe("allow-rm-project-tmp", () => { args: { file_path: "/tmp/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowRmProjectTmp.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-rmdir-project-tmp.test.ts b/policies/tests/allow-rmdir-project-tmp.test.ts new file mode 100644 index 0000000..1eb4f09 --- /dev/null +++ b/policies/tests/allow-rmdir-project-tmp.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowRmdirProjectTmp from "../allow-rmdir-project-tmp"; + +const run = adaptHandler(allowRmdirProjectTmp.action!, allowRmdirProjectTmp.handler as any); + +const PROJECT = "/home/user/project"; + +function bash(command: string, cwd = PROJECT, projectRoot: string | null = PROJECT): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd, env: {}, projectRoot }, + }; +} + +describe("allow-rmdir-project-tmp", () => { + describe("allows rmdir of tmp/ and its subdirs", () => { + const allowed = [ + "rmdir tmp", + "rmdir ./tmp", + `rmdir ${PROJECT}/tmp`, + "rmdir tmp/foo", + "rmdir -p tmp/foo/bar", + "rmdir --ignore-fail-on-non-empty tmp", + "rmdir tmp/a tmp/b", + ]; + + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("rejects rmdir outside tmp", () => { + const rejected = [ + "rmdir src", + "rmdir ./dist", + `rmdir ${PROJECT}`, + "rmdir /etc/something", + "rmdir tmp ../other", + "rmdir tmp /tmp/escape", + ]; + + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rejects rmdir with no path args", () => { + it("rejects bare rmdir", async () => { + const result = await run(bash("rmdir")); + expect(result.verdict).toBe(NEXT); + }); + + it("rejects rmdir -p (no path)", async () => { + const result = await run(bash("rmdir -p")); + expect(result.verdict).toBe(NEXT); + }); + }); + + describe("rejects compound commands", () => { + const rejected = [ + "rmdir tmp && rm -rf /", + "rmdir tmp; echo pwned", + "rmdir $(echo tmp)", + ]; + + for (const cmd of rejected) { + it(`rejects: ${JSON.stringify(cmd)}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + it("passes through when no project root", async () => { + const result = await run(bash("rmdir tmp", PROJECT, null)); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through non-rmdir commands", async () => { + const result = await run(bash("rm tmp/foo")); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/allow-safe-read-commands.test.ts b/policies/tests/allow-safe-read-commands.test.ts index be84fbe..a89fc99 100644 --- a/policies/tests/allow-safe-read-commands.test.ts +++ b/policies/tests/allow-safe-read-commands.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowSafeReadCommands from "../allow-safe-read-commands"; +const run = adaptHandler(allowSafeReadCommands.action!, allowSafeReadCommands.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string, cwd = PROJECT, projectRoot: string | null = PROJECT): ToolCall { @@ -40,7 +42,7 @@ describe("allow-safe-read-commands", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowSafeReadCommands.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -58,7 +60,7 @@ describe("allow-safe-read-commands", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowSafeReadCommands.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -79,7 +81,7 @@ describe("allow-safe-read-commands", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowSafeReadCommands.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -96,7 +98,7 @@ describe("allow-safe-read-commands", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowSafeReadCommands.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -114,7 +116,7 @@ describe("allow-safe-read-commands", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowSafeReadCommands.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -128,7 +130,7 @@ describe("allow-safe-read-commands", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowSafeReadCommands.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -136,19 +138,79 @@ describe("allow-safe-read-commands", () => { describe("allows jq with no file args (reads stdin) when cwd is in project", () => { it("allows jq with filter only", async () => { - const result = await allowSafeReadCommands.handler(bash("jq '.'")); + const result = await run(bash("jq '.'")); expect(result.verdict).toBe(ALLOW); }); }); + describe("allows fx within project", () => { + const allowed = [ + `fx '.version' < ${PROJECT}/package.json`, + "fx '.version' < package.json", + "fx '.users[0].name' < data.json", + `fx ${PROJECT}/data.json`, + "fx 'this.fixtures.length' < e2e/fixtures.json", + ]; + + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("rejects fx with files outside project", () => { + const rejected = [ + "fx '.x' < /etc/passwd", + "fx '.x' < /home/user/other/data.json", + ]; + + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("constrains `<` stdin-redirect targets to project", () => { + const rejected = [ + "cat < /etc/passwd", + "head < /etc/hosts", + "wc -l < /etc/services", + "jq '.' < /etc/secrets.json", + ]; + + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + + const allowed = [ + "cat < README.md", + "head < src/index.ts", + "wc -l < src/index.ts", + ]; + + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + describe("rejects bare commands when cwd is outside project", () => { it("rejects cat with no args in /tmp", async () => { - const result = await allowSafeReadCommands.handler(bash("cat", "/tmp")); + const result = await run(bash("cat", "/tmp")); expect(result.verdict).toBe(NEXT); }); it("rejects wc -l with no file in /tmp", async () => { - const result = await allowSafeReadCommands.handler(bash("wc -l", "/tmp")); + const result = await run(bash("wc -l", "/tmp")); expect(result.verdict).toBe(NEXT); }); }); @@ -162,7 +224,7 @@ describe("allow-safe-read-commands", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowSafeReadCommands.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -177,14 +239,14 @@ describe("allow-safe-read-commands", () => { for (const cmd of rejected) { it(`rejects: ${JSON.stringify(cmd)}`, async () => { - const result = await allowSafeReadCommands.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } }); it("passes through when no project root", async () => { - const result = await allowSafeReadCommands.handler(bash("cat README.md", PROJECT, null)); + const result = await run(bash("cat README.md", PROJECT, null)); expect(result.verdict).toBe(NEXT); }); @@ -194,12 +256,12 @@ describe("allow-safe-read-commands", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowSafeReadCommands.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); it("passes through non-safe commands", async () => { - const result = await allowSafeReadCommands.handler(bash("rm -rf src")); + const result = await run(bash("rm -rf src")); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-search-in-project.test.ts b/policies/tests/allow-search-in-project.test.ts index c5bbaaa..ea67624 100644 --- a/policies/tests/allow-search-in-project.test.ts +++ b/policies/tests/allow-search-in-project.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowSearchInProject from "../allow-search-in-project"; +const run = adaptHandler(allowSearchInProject.action!, allowSearchInProject.handler as any); + const PROJECT = "/home/user/project"; function search(path: string | undefined, projectRoot: string | null = PROJECT): ToolCall { @@ -17,61 +19,61 @@ function search(path: string | undefined, projectRoot: string | null = PROJECT): describe("allow-search-in-project", () => { describe("allows search within project", () => { it("allows explicit path in project", async () => { - const result = await allowSearchInProject.handler(search("/home/user/project/src")); + const result = await run(search("/home/user/project/src")); expect(result.verdict).toBe(ALLOW); }); it("allows project root as path", async () => { - const result = await allowSearchInProject.handler(search("/home/user/project")); + const result = await run(search("/home/user/project")); expect(result.verdict).toBe(ALLOW); }); it("allows nested path", async () => { - const result = await allowSearchInProject.handler(search("/home/user/project/a/b/c")); + const result = await run(search("/home/user/project/a/b/c")); expect(result.verdict).toBe(ALLOW); }); it("allows when no path specified (defaults to cwd)", async () => { - const result = await allowSearchInProject.handler(search(undefined)); + const result = await run(search(undefined)); expect(result.verdict).toBe(ALLOW); }); it("allows relative path within project", async () => { - const result = await allowSearchInProject.handler(search("src/utils")); + const result = await run(search("src/utils")); expect(result.verdict).toBe(ALLOW); }); it("allows relative path with dot prefix", async () => { - const result = await allowSearchInProject.handler(search("./toolgate/policies")); + const result = await run(search("./toolgate/policies")); expect(result.verdict).toBe(ALLOW); }); }); describe("rejects search outside project", () => { it("rejects path outside project", async () => { - const result = await allowSearchInProject.handler(search("/etc")); + const result = await run(search("/etc")); expect(result.verdict).toBe(NEXT); }); it("rejects sibling directory", async () => { - const result = await allowSearchInProject.handler(search("/home/user/other-project")); + const result = await run(search("/home/user/other-project")); expect(result.verdict).toBe(NEXT); }); it("rejects prefix trick", async () => { - const result = await allowSearchInProject.handler(search("/home/user/project-evil/src")); + const result = await run(search("/home/user/project-evil/src")); expect(result.verdict).toBe(NEXT); }); }); describe("passes through when no project root", () => { it("with explicit path", async () => { - const result = await allowSearchInProject.handler(search("/home/user/project/src", null)); + const result = await run(search("/home/user/project/src", null)); expect(result.verdict).toBe(NEXT); }); it("with no path", async () => { - const result = await allowSearchInProject.handler(search(undefined, null)); + const result = await run(search(undefined, null)); expect(result.verdict).toBe(NEXT); }); }); @@ -83,7 +85,7 @@ describe("allow-search-in-project", () => { args: { query: "foo" }, context: { cwd: "/tmp", env: {}, projectRoot: PROJECT }, }; - const result = await allowSearchInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); @@ -94,7 +96,7 @@ describe("allow-search-in-project", () => { args: { command: "search foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowSearchInProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); @@ -110,22 +112,22 @@ describe("allow-search-in-project", () => { } it("allows Glob with no path", async () => { - const result = await allowSearchInProject.handler(glob(undefined)); + const result = await run(glob(undefined)); expect(result.verdict).toBe(ALLOW); }); it("allows Glob with absolute path in project", async () => { - const result = await allowSearchInProject.handler(glob("/home/user/project/src")); + const result = await run(glob("/home/user/project/src")); expect(result.verdict).toBe(ALLOW); }); it("allows Glob with relative path", async () => { - const result = await allowSearchInProject.handler(glob("toolgate/policies")); + const result = await run(glob("toolgate/policies")); expect(result.verdict).toBe(ALLOW); }); it("rejects Glob with path outside project", async () => { - const result = await allowSearchInProject.handler(glob("/etc")); + const result = await run(glob("/etc")); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-skill-crud.test.ts b/policies/tests/allow-skill-crud.test.ts new file mode 100644 index 0000000..60e344a --- /dev/null +++ b/policies/tests/allow-skill-crud.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowSkillCrud from "../allow-skill-crud"; + +const run = adaptHandler(allowSkillCrud.action!, allowSkillCrud.handler as any); + +const PROJECT = "/home/user/project"; +const HOME = process.env.HOME || "/home/user"; + +const makeCall = ( + tool: string, + args: Record, + projectRoot = PROJECT, +): ToolCall => ({ + tool, + args, + context: { cwd: PROJECT, env: {}, projectRoot }, +}); + +describe("allow-skill-crud", () => { + // User-level skills (~/.claude/skills/) + it("allows Read on ~/.claude/skills/ file", async () => { + const result = await run( + makeCall("Read", { file_path: `${HOME}/.claude/skills/my-skill.md` }), + ); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows Write on ~/.claude/skills/ file", async () => { + const result = await run( + makeCall("Write", { + file_path: `${HOME}/.claude/skills/new-skill.md`, + content: "# Skill", + }), + ); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows Edit on ~/.claude/skills/ file", async () => { + const result = await run( + makeCall("Edit", { + file_path: `${HOME}/.claude/skills/my-skill.md`, + old_string: "old", + new_string: "new", + }), + ); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows Glob targeting ~/.claude/skills/", async () => { + const result = await run( + makeCall("Glob", { + pattern: "*.md", + path: `${HOME}/.claude/skills`, + }), + ); + expect(result.verdict).toBe(ALLOW); + }); + + // Project-level skills (/.claude/skills/) + it("allows Read on project .claude/skills/ file", async () => { + const result = await run( + makeCall("Read", { + file_path: `${PROJECT}/.claude/skills/project-skill.md`, + }), + ); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows Write on project .claude/skills/ file", async () => { + const result = await run( + makeCall("Write", { + file_path: `${PROJECT}/.claude/skills/project-skill.md`, + content: "# Skill", + }), + ); + expect(result.verdict).toBe(ALLOW); + }); + + it("allows Edit on project .claude/skills/ file", async () => { + const result = await run( + makeCall("Edit", { + file_path: `${PROJECT}/.claude/skills/project-skill.md`, + old_string: "old", + new_string: "new", + }), + ); + expect(result.verdict).toBe(ALLOW); + }); + + // Pass-through cases + it("passes through Read on non-skill path", async () => { + const result = await run( + makeCall("Read", { file_path: `${HOME}/.claude/settings.json` }), + ); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through Write on non-skill .claude path", async () => { + const result = await run( + makeCall("Write", { + file_path: `${HOME}/.claude/settings.json`, + content: "{}", + }), + ); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through unrelated tools", async () => { + const result = await run( + makeCall("Bash", { command: "echo hello" }), + ); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through when file_path is not a string", async () => { + const result = await run( + makeCall("Read", { file_path: 123 }), + ); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through Glob without path targeting skills", async () => { + const result = await run( + makeCall("Glob", { pattern: "*.md", path: "/tmp" }), + ); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/allow-sleep.test.ts b/policies/tests/allow-sleep.test.ts index 5484e70..45fe725 100644 --- a/policies/tests/allow-sleep.test.ts +++ b/policies/tests/allow-sleep.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowSleep from "../allow-sleep"; +const run = adaptHandler(allowSleep.action!, allowSleep.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -23,7 +25,7 @@ describe("allow-sleep", () => { for (const cmd of allowed) { it(`allows: ${cmd}`, async () => { - const result = await allowSleep.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(ALLOW); }); } @@ -44,7 +46,7 @@ describe("allow-sleep", () => { for (const cmd of rejected) { it(`rejects: ${cmd}`, async () => { - const result = await allowSleep.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -56,7 +58,7 @@ describe("allow-sleep", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await allowSleep.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-superpowers-skills.test.ts b/policies/tests/allow-superpowers-skills.test.ts index 52999ca..68978b7 100644 --- a/policies/tests/allow-superpowers-skills.test.ts +++ b/policies/tests/allow-superpowers-skills.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowSuperpowersSkills from "../allow-superpowers-skills"; +const run = adaptHandler(allowSuperpowersSkills.action!, allowSuperpowersSkills.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record): ToolCall => ({ @@ -12,56 +14,56 @@ const makeCall = (tool: string, args: Record): ToolCall => ({ describe("allow-superpowers-skills", () => { it("allows superpowers:executing-plans", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Skill", { skill: "superpowers:executing-plans" }), ); expect(result.verdict).toBe(ALLOW); }); it("allows superpowers:subagent-driven-development", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Skill", { skill: "superpowers:subagent-driven-development" }), ); expect(result.verdict).toBe(ALLOW); }); it("allows superpowers:brainstorming", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Skill", { skill: "superpowers:brainstorming" }), ); expect(result.verdict).toBe(ALLOW); }); it("allows bare superpowers skill", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Skill", { skill: "superpowers" }), ); expect(result.verdict).toBe(ALLOW); }); it("allows superpowers skill with args", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Skill", { skill: "superpowers:write-plan", args: "some args" }), ); expect(result.verdict).toBe(ALLOW); }); it("passes through non-superpowers skills", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Skill", { skill: "commit" }), ); expect(result.verdict).toBe(NEXT); }); it("passes through non-Skill tools", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Bash", { command: "echo hello" }), ); expect(result.verdict).toBe(NEXT); }); it("passes through when skill arg is not a string", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Skill", { skill: 123 }), ); expect(result.verdict).toBe(NEXT); @@ -69,28 +71,28 @@ describe("allow-superpowers-skills", () => { // Agent tool tests it("allows Agent with superpowers:code-reviewer subagent_type", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Agent", { subagent_type: "superpowers:code-reviewer", prompt: "Review code" }), ); expect(result.verdict).toBe(ALLOW); }); it("allows Agent with superpowers:dispatching-parallel-agents subagent_type", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Agent", { subagent_type: "superpowers:dispatching-parallel-agents", prompt: "Run tasks" }), ); expect(result.verdict).toBe(ALLOW); }); it("passes through Agent with non-superpowers subagent_type", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Agent", { subagent_type: "general-purpose", prompt: "Do stuff" }), ); expect(result.verdict).toBe(NEXT); }); it("passes through Agent with non-string subagent_type", async () => { - const result = await allowSuperpowersSkills.handler( + const result = await run( makeCall("Agent", { subagent_type: 42, prompt: "Do stuff" }), ); expect(result.verdict).toBe(NEXT); diff --git a/policies/tests/allow-task-create.test.ts b/policies/tests/allow-task-create.test.ts index 68b73c4..4aeec45 100644 --- a/policies/tests/allow-task-create.test.ts +++ b/policies/tests/allow-task-create.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowTaskCreate from "../allow-task-create"; +const run = adaptHandler(allowTaskCreate.action!, allowTaskCreate.handler as any); + const PROJECT = "/home/user/project"; describe("allow-task-create", () => { @@ -11,7 +13,7 @@ describe("allow-task-create", () => { args: { subject: "Fix bug", description: "Fix the auth bug" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowTaskCreate.handler(call); + const result = await run(call); expect(result.verdict).toBe(ALLOW); }); @@ -21,7 +23,7 @@ describe("allow-task-create", () => { args: { command: "echo hello" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowTaskCreate.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); @@ -31,7 +33,7 @@ describe("allow-task-create", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowTaskCreate.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-task-crud.test.ts b/policies/tests/allow-task-crud.test.ts index 30b8c20..1b66279 100644 --- a/policies/tests/allow-task-crud.test.ts +++ b/policies/tests/allow-task-crud.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowTaskCrud from "../allow-task-crud"; +const run = adaptHandler(allowTaskCrud.action!, allowTaskCrud.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record = {}): ToolCall => ({ @@ -13,18 +15,18 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-task-crud", () => { for (const tool of ["TaskCreate", "TaskUpdate", "TaskGet", "TaskList", "TaskOutput", "TaskStop"]) { it(`allows ${tool}`, async () => { - const result = await allowTaskCrud.handler(makeCall(tool)); + const result = await run(makeCall(tool)); expect(result.verdict).toBe(ALLOW); }); } it("passes through non-Task tools", async () => { - const result = await allowTaskCrud.handler(makeCall("Bash", { command: "echo hello" })); + const result = await run(makeCall("Bash", { command: "echo hello" })); expect(result.verdict).toBe(NEXT); }); it("passes through other tools", async () => { - const result = await allowTaskCrud.handler(makeCall("Read", { file_path: "/foo" })); + const result = await run(makeCall("Read", { file_path: "/foo" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-toolgate-test.test.ts b/policies/tests/allow-toolgate-test.test.ts new file mode 100644 index 0000000..29c5fc8 --- /dev/null +++ b/policies/tests/allow-toolgate-test.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowToolgateTest from "../allow-toolgate-test"; + +const run = adaptHandler(allowToolgateTest.action!, allowToolgateTest.handler as any); + +const PROJECT = "/home/user/project"; + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, + }; +} + +describe("allow-toolgate-test", () => { + describe("allows toolgate test invocations", () => { + const allowed = [ + `toolgate test Bash '{"command": "rm -rf /etc/passwd"}'`, + `toolgate test Bash '{"command": "ls"}'`, + `toolgate test Bash '{"command": "rm -rf ./tmp/foo"}' --cwd /home/user/project`, + `toolgate test Read '{"file_path": "/etc/shadow"}'`, + `toolgate test --json Bash '{"command": "git push"}'`, + ]; + + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("does not allow other toolgate subcommands", () => { + const rejected = [ + "toolgate run", + "toolgate audit", + "toolgate disable", + "toolgate list", + "toolgate", + ]; + + for (const cmd of rejected) { + it(`passes through: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rejects compound commands and substitution", () => { + const rejected = [ + `toolgate test Bash '{"command": "ls"}' && rm -rf /`, + `toolgate test Bash '{"command": "ls"}' | grep DENY`, + `toolgate test Bash $(echo something)`, + `toolgate test Bash '{"command": "ls"}' > /tmp/out`, + ]; + + for (const cmd of rejected) { + it(`rejects: ${JSON.stringify(cmd)}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + it("passes through unrelated commands", async () => { + const result = await run(bash("ls -la")); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/allow-version-probes.test.ts b/policies/tests/allow-version-probes.test.ts new file mode 100644 index 0000000..d1c0ccc --- /dev/null +++ b/policies/tests/allow-version-probes.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowVersionProbes from "../allow-version-probes"; + +const run = adaptHandler(allowVersionProbes.action!, allowVersionProbes.handler as any); + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; +} + +describe("allow-version-probes", () => { + describe("allows version/help probes", () => { + const allowed = [ + "node --version", + "bun --version", + "jq --version", + "ffmpeg -version", + "magick -version", + "cwebp -version", + "git --help", + "docker --help", + "node --version | head -1", + "ffmpeg -version 2>&1", + "ffmpeg -version | head -1", + "kubectl --version", + ]; + + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("rejects ambiguous or extra-arg forms", () => { + const rejected = [ + // Extra positional after the flag — could be interpreted by the tool + "ffmpeg -version foo.mp4", + "node --version script.js", + // Single-letter flags are too ambiguous (e.g. `ls -h` is human-readable) + "ls -h", + "node -v", + "node -V", + "python -V", + // Subcommand form, not a flag probe + "git version", + "docker version", + // No flag at all + "node", + "ffmpeg", + // Multiple flags (could compound into something non-trivial) + "node --version --foo", + ]; + + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("rejects unsafe shell constructs", () => { + const rejected = [ + "node --version && rm -rf /", + "node --version; curl evil.com", + "node --version > /etc/passwd", + "node $(echo --version)", + "node `id`", + "node --version &", + ]; + + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + it("passes through non-Bash tools", async () => { + const call: ToolCall = { + tool: "Read", + args: {}, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; + const result = await run(call); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/allow-web-fetch.test.ts b/policies/tests/allow-web-fetch.test.ts index 1309ba9..e45e1ba 100644 --- a/policies/tests/allow-web-fetch.test.ts +++ b/policies/tests/allow-web-fetch.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowWebFetch from "../allow-web-fetch"; +const run = adaptHandler(allowWebFetch.action!, allowWebFetch.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record = {}): ToolCall => ({ @@ -12,12 +14,12 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-web-fetch", () => { it("allows WebFetch", async () => { - const result = await allowWebFetch.handler(makeCall("WebFetch", { url: "https://example.com", prompt: "summarize" })); + const result = await run(makeCall("WebFetch", { url: "https://example.com", prompt: "summarize" })); expect(result.verdict).toBe(ALLOW); }); it("passes through non-WebFetch tools", async () => { - const result = await allowWebFetch.handler(makeCall("Bash", { command: "echo hello" })); + const result = await run(makeCall("Bash", { command: "echo hello" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-web-search.test.ts b/policies/tests/allow-web-search.test.ts index 1506e16..a8379ca 100644 --- a/policies/tests/allow-web-search.test.ts +++ b/policies/tests/allow-web-search.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowWebSearch from "../allow-web-search"; +const run = adaptHandler(allowWebSearch.action!, allowWebSearch.handler as any); + const PROJECT = "/home/user/project"; const makeCall = (tool: string, args: Record = {}): ToolCall => ({ @@ -12,12 +14,12 @@ const makeCall = (tool: string, args: Record = {}): ToolCall => describe("allow-web-search", () => { it("allows WebSearch", async () => { - const result = await allowWebSearch.handler(makeCall("WebSearch", { query: "test query" })); + const result = await run(makeCall("WebSearch", { query: "test query" })); expect(result.verdict).toBe(ALLOW); }); it("passes through non-WebSearch tools", async () => { - const result = await allowWebSearch.handler(makeCall("Bash", { command: "echo hello" })); + const result = await run(makeCall("Bash", { command: "echo hello" })); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-webfetch-claude.test.ts b/policies/tests/allow-webfetch-claude.test.ts index 2148831..bac51c0 100644 --- a/policies/tests/allow-webfetch-claude.test.ts +++ b/policies/tests/allow-webfetch-claude.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import allowWebFetchClaude from "../allow-webfetch-claude"; +const run = adaptHandler(allowWebFetchClaude.action!, allowWebFetchClaude.handler as any); + const PROJECT = "/home/user/project"; function webfetch(url: string): ToolCall { @@ -15,39 +17,39 @@ function webfetch(url: string): ToolCall { describe("allow-webfetch-claude", () => { describe("allows claude.com URLs", () => { it("allows docs.claude.com", async () => { - const result = await allowWebFetchClaude.handler(webfetch("https://docs.claude.com/page")); + const result = await run(webfetch("https://docs.claude.com/page")); expect(result.verdict).toBe(ALLOW); }); it("allows claude.com root", async () => { - const result = await allowWebFetchClaude.handler(webfetch("https://claude.com/")); + const result = await run(webfetch("https://claude.com/")); expect(result.verdict).toBe(ALLOW); }); it("allows subdomain.claude.com", async () => { - const result = await allowWebFetchClaude.handler(webfetch("https://api.claude.com/v1/messages")); + const result = await run(webfetch("https://api.claude.com/v1/messages")); expect(result.verdict).toBe(ALLOW); }); it("allows deep subdomain", async () => { - const result = await allowWebFetchClaude.handler(webfetch("https://a.b.claude.com/path")); + const result = await run(webfetch("https://a.b.claude.com/path")); expect(result.verdict).toBe(ALLOW); }); }); describe("rejects non-claude URLs", () => { it("rejects other domains", async () => { - const result = await allowWebFetchClaude.handler(webfetch("https://example.com")); + const result = await run(webfetch("https://example.com")); expect(result.verdict).toBe(NEXT); }); it("rejects similar-looking domains", async () => { - const result = await allowWebFetchClaude.handler(webfetch("https://notclaude.com/page")); + const result = await run(webfetch("https://notclaude.com/page")); expect(result.verdict).toBe(NEXT); }); it("rejects claude.com as subdomain of another domain", async () => { - const result = await allowWebFetchClaude.handler(webfetch("https://evil-claude.com/page")); + const result = await run(webfetch("https://evil-claude.com/page")); expect(result.verdict).toBe(NEXT); }); }); @@ -58,12 +60,12 @@ describe("allow-webfetch-claude", () => { args: { command: "curl https://claude.com" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await allowWebFetchClaude.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); it("passes through invalid URLs", async () => { - const result = await allowWebFetchClaude.handler(webfetch("not-a-url")); + const result = await run(webfetch("not-a-url")); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/allow-which.test.ts b/policies/tests/allow-which.test.ts new file mode 100644 index 0000000..d4407e2 --- /dev/null +++ b/policies/tests/allow-which.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, ALLOW, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import allowWhich from "../allow-which"; + +const run = adaptHandler(allowWhich.action!, allowWhich.handler as any); + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; +} + +describe("allow-which", () => { + describe("allows safe which invocations", () => { + const allowed = [ + "which jq", + "which node", + "which hyperfine", + "which cwebp magick", + "which rg sd choose xq htmlq pup dasel mlr", + "which terminal-notifier 2>/dev/null", + "which xq 2>&1", + "which jq | head -1", + "which node | wc -l", + "which -a node", + ]; + + for (const cmd of allowed) { + it(`allows: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(ALLOW); + }); + } + }); + + describe("rejects unsafe patterns", () => { + const rejected = [ + "which jq && jq --version", + "which node; node --version", + "which $(echo jq)", + "which `id`", + "which jq > /etc/passwd", + "which jq &", + ]; + + for (const cmd of rejected) { + it(`rejects: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + it("passes through non-Bash tools", async () => { + const call: ToolCall = { + tool: "Read", + args: {}, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; + const result = await run(call); + expect(result.verdict).toBe(NEXT); + }); + + it("passes through non-which bash commands", async () => { + const result = await run(bash("echo hello")); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/deny-and-chains.test.ts b/policies/tests/deny-and-chains.test.ts new file mode 100644 index 0000000..b3b97f6 --- /dev/null +++ b/policies/tests/deny-and-chains.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import denyAndChains from "../deny-and-chains"; + +const run = adaptHandler(denyAndChains.action!, denyAndChains.handler as any); + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; +} + +describe("deny-and-chains", () => { + describe("denies impure && chains with steering", () => { + const cases = [ + "mkdir tmp && ls tmp", + "mkdir -p tmp && date", + "python3 -c 'print(1)' && wc -l file", + "ls /a && ls /b", + "ls /a && cat /b", + "rm out && rmdir tmp", + "sleep 5 && gh pr list", + "ls /a && ls /b && ls /c", + "grep foo file | head > out && wc -l out", + "ls A && grep x file | head", + "claude-session-search --deep > out.txt && wc -l out.txt", + ]; + for (const cmd of cases) { + it(`denies: ${cmd}`, async () => { + const r = await run(bash(cmd)); + expect(r.verdict).toBe(DENY); + expect(r.reason).toMatch(/separate Bash call|atomically/); + }); + } + }); + + describe("exempts env-setter chains", () => { + const cases = [ + 'eval "$(fnm env)" && fnm use 25', + "eval \"$(fnm env)\" && fnm use 25 && codex", + "source .env && cmd", + ". .env && cmd", + "export FOO=bar && cmd", + "cmd && source .env", + "cmd1 && eval \"$(fnm env)\" && cmd2", + ]; + for (const cmd of cases) { + it(`passes through: ${cmd}`, async () => { + const r = await run(bash(cmd)); + expect(r.verdict).toBe(NEXT); + }); + } + }); + + describe("doesn't fire on non-chain commands", () => { + const cases = [ + "ls", + "ls /a /b /c", + "grep foo file | head -10", + "cat file > out", + "cd /some/path", + "git status", + ]; + for (const cmd of cases) { + it(`passes through: ${cmd}`, async () => { + const r = await run(bash(cmd)); + expect(r.verdict).toBe(NEXT); + }); + } + }); + + describe("out-of-scope separators (|| and ;) — not handled", () => { + const cases = [ + "cmd1 || cmd2", + "cmd1 ; cmd2", + "ls /a ; ls /b", + ]; + for (const cmd of cases) { + it(`passes through (intentional): ${cmd}`, async () => { + const r = await run(bash(cmd)); + expect(r.verdict).toBe(NEXT); + }); + } + }); + + it("passes through non-Bash tools", async () => { + const call: ToolCall = { + tool: "Read", + args: { file_path: "/foo" }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; + const r = await run(call); + expect(r.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/deny-bash-grep.test.ts b/policies/tests/deny-bash-grep.test.ts index f9acbe3..ad9f585 100644 --- a/policies/tests/deny-bash-grep.test.ts +++ b/policies/tests/deny-bash-grep.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import denyBashGrep from "../deny-bash-grep"; +const run = adaptHandler(denyBashGrep.action!, denyBashGrep.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string): ToolCall { @@ -27,7 +29,7 @@ describe("deny-bash-grep", () => { for (const cmd of denied) { it(`denies: ${cmd}`, async () => { - const result = await denyBashGrep.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -43,7 +45,7 @@ describe("deny-bash-grep", () => { for (const cmd of ignored) { it(`passes through: ${cmd}`, async () => { - const result = await denyBashGrep.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -55,7 +57,7 @@ describe("deny-bash-grep", () => { args: { pattern: "foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await denyBashGrep.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/deny-cd-chained.test.ts b/policies/tests/deny-cd-chained.test.ts index ef4b2ca..1524aca 100644 --- a/policies/tests/deny-cd-chained.test.ts +++ b/policies/tests/deny-cd-chained.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import denyCdChained from "../deny-cd-chained"; +const run = adaptHandler(denyCdChained.action!, denyCdChained.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string): ToolCall { @@ -23,7 +25,7 @@ describe("deny-cd-chained", () => { for (const cmd of denied) { it(`denies: ${cmd}`, async () => { - const result = await denyCdChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -37,7 +39,7 @@ describe("deny-cd-chained", () => { for (const cmd of denied) { it(`denies: ${cmd}`, async () => { - const result = await denyCdChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -45,7 +47,7 @@ describe("deny-cd-chained", () => { describe("denies cd chained with ||", () => { it("denies: cd /tmp || echo failed", async () => { - const result = await denyCdChained.handler(bash("cd /tmp || echo failed")); + const result = await run(bash("cd /tmp || echo failed")); expect(result.verdict).toBe(DENY); }); }); @@ -60,7 +62,7 @@ describe("deny-cd-chained", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyCdChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -76,7 +78,7 @@ describe("deny-cd-chained", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyCdChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -88,7 +90,7 @@ describe("deny-cd-chained", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await denyCdChained.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/deny-gh-heredoc.test.ts b/policies/tests/deny-gh-heredoc.test.ts index f41eb43..e2cba78 100644 --- a/policies/tests/deny-gh-heredoc.test.ts +++ b/policies/tests/deny-gh-heredoc.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import denyGhHeredoc from "../deny-gh-heredoc"; +const run = adaptHandler(denyGhHeredoc.action!, denyGhHeredoc.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string): ToolCall { @@ -24,9 +26,9 @@ describe("deny-gh-heredoc", () => { for (const cmd of denied) { it(`denies: ${cmd.slice(0, 60)}...`, async () => { - const result = await denyGhHeredoc.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); - expect(result.reason).toContain("--body-file"); + expect((result as any).reason).toContain("--body-file"); }); } }); @@ -40,9 +42,9 @@ describe("deny-gh-heredoc", () => { for (const cmd of denied) { it(`denies: ${cmd.slice(0, 60)}...`, async () => { - const result = await denyGhHeredoc.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); - expect(result.reason).toContain("git commit -F"); + expect((result as any).reason).toContain("git commit -F"); }); } }); @@ -56,9 +58,9 @@ describe("deny-gh-heredoc", () => { for (const cmd of denied) { it(`denies: ${cmd.slice(0, 60)}...`, async () => { - const result = await denyGhHeredoc.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); - expect(result.reason).toContain("--body-file"); + expect((result as any).reason).toContain("--body-file"); }); } }); @@ -70,9 +72,9 @@ describe("deny-gh-heredoc", () => { for (const cmd of denied) { it(`denies: ${cmd.slice(0, 60)}...`, async () => { - const result = await denyGhHeredoc.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); - expect(result.reason).toContain("git commit -F"); + expect((result as any).reason).toContain("git commit -F"); }); } }); @@ -90,7 +92,7 @@ describe("deny-gh-heredoc", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyGhHeredoc.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -107,7 +109,7 @@ describe("deny-gh-heredoc", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyGhHeredoc.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -119,14 +121,12 @@ describe("deny-gh-heredoc", () => { args: { file_path: "/tmp/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await denyGhHeredoc.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); it("ignores non-gh/git bash commands with substitution", async () => { - const result = await denyGhHeredoc.handler( - bash('echo "$(whoami)"'), - ); + const result = await run(bash('echo "$(whoami)"')); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/deny-git-add-and-commit.test.ts b/policies/tests/deny-git-add-and-commit.test.ts index 924604b..7c6c07c 100644 --- a/policies/tests/deny-git-add-and-commit.test.ts +++ b/policies/tests/deny-git-add-and-commit.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import denyGitAddAndCommit from "../deny-git-add-and-commit"; +const run = adaptHandler(denyGitAddAndCommit.action!, denyGitAddAndCommit.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -23,7 +25,7 @@ describe("deny-git-add-and-commit", () => { for (const cmd of denied) { it(`denies: ${JSON.stringify(cmd)}`, async () => { - const result = await denyGitAddAndCommit.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -38,7 +40,7 @@ describe("deny-git-add-and-commit", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyGitAddAndCommit.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -52,7 +54,7 @@ describe("deny-git-add-and-commit", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyGitAddAndCommit.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -70,7 +72,7 @@ describe("deny-git-add-and-commit", () => { for (const cmd of denied) { it(`denies: ${JSON.stringify(cmd)}`, async () => { - const result = await denyGitAddAndCommit.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -85,7 +87,7 @@ describe("deny-git-add-and-commit", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyGitAddAndCommit.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -97,7 +99,7 @@ describe("deny-git-add-and-commit", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await denyGitAddAndCommit.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); -}); \ No newline at end of file +}); diff --git a/policies/tests/deny-git-chained.test.ts b/policies/tests/deny-git-chained.test.ts index 7088feb..ca42771 100644 --- a/policies/tests/deny-git-chained.test.ts +++ b/policies/tests/deny-git-chained.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import denyGitChained from "../deny-git-chained"; +const run = adaptHandler(denyGitChained.action!, denyGitChained.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string): ToolCall { @@ -24,7 +26,7 @@ describe("deny-git-chained", () => { for (const cmd of denied) { it(`denies: ${cmd}`, async () => { - const result = await denyGitChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -38,7 +40,7 @@ describe("deny-git-chained", () => { for (const cmd of denied) { it(`denies: ${cmd}`, async () => { - const result = await denyGitChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -46,9 +48,7 @@ describe("deny-git-chained", () => { describe("denies git chained with ||", () => { it("denies: git pull || git fetch", async () => { - const result = await denyGitChained.handler( - bash("git pull || git fetch"), - ); + const result = await run(bash("git pull || git fetch")); expect(result.verdict).toBe(DENY); }); }); @@ -65,7 +65,7 @@ describe("deny-git-chained", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyGitChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -80,7 +80,7 @@ describe("deny-git-chained", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyGitChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -95,7 +95,7 @@ describe("deny-git-chained", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyGitChained.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -107,7 +107,7 @@ describe("deny-git-chained", () => { args: { file_path: "/foo" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await denyGitChained.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/deny-mixed-pure-chains.test.ts b/policies/tests/deny-mixed-pure-chains.test.ts index 08d33bd..d89c6ab 100644 --- a/policies/tests/deny-mixed-pure-chains.test.ts +++ b/policies/tests/deny-mixed-pure-chains.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import denyMixedPureChains from "../deny-mixed-pure-chains"; +const run = adaptHandler(denyMixedPureChains.action!, denyMixedPureChains.handler as any); + function bash(command: string): ToolCall { return { tool: "Bash", @@ -26,7 +28,7 @@ describe("deny-mixed-pure-chains", () => { for (const cmd of denied) { it(`denies: ${JSON.stringify(cmd)}`, async () => { - const result = await denyMixedPureChains.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -42,7 +44,7 @@ describe("deny-mixed-pure-chains", () => { for (const cmd of allowed) { it(`passes through: ${JSON.stringify(cmd)}`, async () => { - const result = await denyMixedPureChains.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -57,7 +59,7 @@ describe("deny-mixed-pure-chains", () => { for (const cmd of allowed) { it(`passes through: ${JSON.stringify(cmd)}`, async () => { - const result = await denyMixedPureChains.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -73,7 +75,7 @@ describe("deny-mixed-pure-chains", () => { for (const cmd of allowed) { it(`passes through: ${cmd}`, async () => { - const result = await denyMixedPureChains.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(NEXT); }); } @@ -85,7 +87,7 @@ describe("deny-mixed-pure-chains", () => { args: {}, context: { cwd: "/tmp", env: {}, projectRoot: null }, }; - const result = await denyMixedPureChains.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/deny-perl-one-liners.test.ts b/policies/tests/deny-perl-one-liners.test.ts new file mode 100644 index 0000000..ecb8552 --- /dev/null +++ b/policies/tests/deny-perl-one-liners.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import denyPerlOneLiners from "../deny-perl-one-liners"; + +const run = adaptHandler(denyPerlOneLiners.action!, denyPerlOneLiners.handler as any); + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; +} + +describe("deny-perl-one-liners", () => { + describe("denies inline perl scripts with steering message", () => { + const cases = [ + "perl -e 'print 1'", + "perl -ne 'print if /foo/'", + "perl -pe 's/foo/bar/'", + "perl -E 'say \"hi\"'", + "perl -ane 'print $F[0]'", + "perl -nle 'print'", + "perl -ple 's/x/y/'", + "perl -pae 'print'", + "cat foo | perl -ne 'print'", + "perl -ne 'print' | sort", + "echo hi && perl -e 'print'", + "perl -ne 'extract'; perl -pe 'modify'", + ]; + for (const cmd of cases) { + it(`denies: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(DENY); + expect(result.reason).toMatch(/rg|sd|sed|choose|htmlq|xq/); + }); + } + }); + + describe("passes through perl invocations without inline scripts", () => { + const cases = [ + "perl script.pl", + "perl -d script.pl", + "perl --version", + "perl -w script.pl", + "perl -Mstrict script.pl", + "perl -I/path/to/lib script.pl", + ]; + for (const cmd of cases) { + it(`passes through: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("does not affect non-perl commands", () => { + const cases = [ + "echo hello", + "ls -la", + "rg -oP 'pattern' --replace '$1'", + "sd find replace", + "cat foo | sed 's/x/y/'", + ]; + for (const cmd of cases) { + it(`passes through: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + it("passes through non-Bash tools", async () => { + const call: ToolCall = { + tool: "Read", + args: { file_path: "/foo" }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; + const result = await run(call); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/deny-wrangler-pipes.test.ts b/policies/tests/deny-wrangler-pipes.test.ts new file mode 100644 index 0000000..dfd2c6b --- /dev/null +++ b/policies/tests/deny-wrangler-pipes.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import denyWranglerPipes from "../deny-wrangler-pipes"; + +const run = adaptHandler(denyWranglerPipes.action!, denyWranglerPipes.handler as any); + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; +} + +describe("deny-wrangler-pipes", () => { + describe("denies pipes into data-wranglers with steering message", () => { + const cases = [ + // Dominant pattern from failure logs: cat | fx + "cat foo.json | fx '.users.map(u => u.name)'", + "cat /Users/bryce/long/path.json | fx 'Object.keys(_)'", + // cat | jq + "cat foo.json | jq '.users[]'", + "cat config.yaml | yq '.services.web'", + // network LHS | wrangler + "gh api repos/x/y | jq '.name'", + "gh issue view 731 --json title,body | fx '.title'", + "xh GET http://api.example.com | fx '.data'", + "xhs api.example.com | jq '.id'", + // chained pipes — first wrangler fires + "cat foo.json | jq '.x' | jq '.y'", + "cat foo.json | gron | grep email | jq '.'", + // XML / HTML wranglers + "cat foo.xml | xq -x '//book/title'", + "cat foo.html | htmlq 'a.link'", + // safe redirect on LHS still triggers + "gh api foo 2>/dev/null | fx '.id'", + ]; + for (const cmd of cases) { + it(`denies: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(DENY); + expect(result.reason).toMatch(/save|tmp\//); + }); + } + }); + + describe("passes through wrangler invocations that don't waste an LHS", () => { + const cases = [ + // Direct file args — no pipe, nothing to waste + "jq '.foo' file.json", + "fx file.json '.foo'", + "yq '.foo' file.yaml", + "xq -x '//book' file.xml", + // Wrangler with no positional filter (rare, but legal) + "cat foo.json | jq", + // gron is a pure transformer, not a filter-bearing wrangler + "cat foo.json | gron", + "cat foo.json | gron | grep email", + // No wrangler at all + "cat foo.json", + "gh api foo", + "echo hi | grep h", + ]; + for (const cmd of cases) { + it(`passes through: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(NEXT); + }); + } + }); + + describe("trivial-filter edge cases still fire (per design — no exceptions)", () => { + const cases = [ + "echo '{}' | jq .", + "cat foo.json | jq .", + ]; + for (const cmd of cases) { + it(`denies: ${cmd}`, async () => { + const result = await run(bash(cmd)); + expect(result.verdict).toBe(DENY); + }); + } + }); + + it("passes through non-Bash tools", async () => { + const call: ToolCall = { + tool: "Read", + args: { file_path: "/foo" }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; + const result = await run(call); + expect(result.verdict).toBe(NEXT); + }); +}); diff --git a/policies/tests/deny-writes-outside-project.test.ts b/policies/tests/deny-writes-outside-project.test.ts index a8265d0..0494ae1 100644 --- a/policies/tests/deny-writes-outside-project.test.ts +++ b/policies/tests/deny-writes-outside-project.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ALLOW, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import denyWritesOutsideProject from "../deny-writes-outside-project"; +const run = adaptHandler(denyWritesOutsideProject.action!, denyWritesOutsideProject.handler as any); + function write(filePath: string, projectRoot: string | null = "/home/user/project"): ToolCall { return { tool: "Write", @@ -29,49 +31,48 @@ function bash(command: string, projectRoot: string | null = "/home/user/project" describe("deny-writes-outside-project", () => { describe("allows writes within project root", () => { it("allows Write to file in project", async () => { - const result = await denyWritesOutsideProject.handler(write("/home/user/project/src/foo.ts")); + const result = await run(write("/home/user/project/src/foo.ts")); expect(result.verdict).toBe(NEXT); }); it("allows Edit to file in project", async () => { - const result = await denyWritesOutsideProject.handler(edit("/home/user/project/src/foo.ts")); + const result = await run(edit("/home/user/project/src/foo.ts")); expect(result.verdict).toBe(NEXT); }); it("allows nested paths", async () => { - const result = await denyWritesOutsideProject.handler(write("/home/user/project/a/b/c/d.ts")); + const result = await run(write("/home/user/project/a/b/c/d.ts")); expect(result.verdict).toBe(NEXT); }); }); describe("denies writes outside project root", () => { it("denies Write to /etc/passwd", async () => { - const result = await denyWritesOutsideProject.handler(write("/etc/passwd")); + const result = await run(write("/etc/passwd")); expect(result.verdict).toBe(DENY); }); it("denies Edit to home directory file", async () => { - const result = await denyWritesOutsideProject.handler(edit("/home/user/.bashrc")); + const result = await run(edit("/home/user/.bashrc")); expect(result.verdict).toBe(DENY); }); it("denies Write to sibling directory", async () => { - const result = await denyWritesOutsideProject.handler(write("/home/user/other-project/foo.ts")); + const result = await run(write("/home/user/other-project/foo.ts")); expect(result.verdict).toBe(DENY); }); }); describe("handles path traversal tricks", () => { it("denies path that is a prefix but not a subdirectory", async () => { - // /home/user/project-evil is not inside /home/user/project - const result = await denyWritesOutsideProject.handler(write("/home/user/project-evil/foo.ts")); + const result = await run(write("/home/user/project-evil/foo.ts")); expect(result.verdict).toBe(DENY); }); }); describe("passes through when no project root", () => { it("passes through Write with no projectRoot", async () => { - const result = await denyWritesOutsideProject.handler(write("/etc/passwd", null)); + const result = await run(write("/etc/passwd", null)); expect(result.verdict).toBe(NEXT); }); }); @@ -83,156 +84,134 @@ describe("deny-writes-outside-project", () => { args: { file_path: "/etc/passwd" }, context: { cwd: "/tmp", env: {}, projectRoot: "/home/user/project" }, }; - const result = await denyWritesOutsideProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); describe("denies Bash redirects outside project", () => { it("denies cat > /outside/path", async () => { - const result = await denyWritesOutsideProject.handler(bash("cat > /etc/passwd")); + const result = await run(bash("cat > /etc/passwd")); expect(result.verdict).toBe(DENY); }); it("denies cat >> /outside/path (append)", async () => { - const result = await denyWritesOutsideProject.handler(bash("cat >> /home/user/.bashrc")); + const result = await run(bash("cat >> /home/user/.bashrc")); expect(result.verdict).toBe(DENY); }); it("denies heredoc redirect outside project", async () => { - const result = await denyWritesOutsideProject.handler( + const result = await run( bash("cat > /home/user/.claude/plans/evil.md << 'EOF'\nsome content\nEOF"), ); expect(result.verdict).toBe(DENY); }); it("denies mkdir -p && cat > /outside/path", async () => { - const result = await denyWritesOutsideProject.handler( + const result = await run( bash("mkdir -p /tmp/foo && cat > /tmp/foo/bar.md"), ); expect(result.verdict).toBe(DENY); }); it("denies tee writing outside project", async () => { - const result = await denyWritesOutsideProject.handler(bash("echo hi | tee /etc/evil")); + const result = await run(bash("echo hi | tee /etc/evil")); expect(result.verdict).toBe(DENY); }); it("allows redirect inside project", async () => { - const result = await denyWritesOutsideProject.handler( + const result = await run( bash("echo hello > /home/user/project/output.txt"), ); expect(result.verdict).toBe(NEXT); }); it("allows Bash with no redirects", async () => { - const result = await denyWritesOutsideProject.handler(bash("echo hello")); + const result = await run(bash("echo hello")); expect(result.verdict).toBe(NEXT); }); it("allows Bash with no projectRoot", async () => { - const result = await denyWritesOutsideProject.handler(bash("cat > /etc/passwd", null)); + const result = await run(bash("cat > /etc/passwd", null)); expect(result.verdict).toBe(NEXT); }); it("denies redirect to sibling project directory", async () => { - const result = await denyWritesOutsideProject.handler( + const result = await run( bash("echo x > /home/user/project-evil/foo.ts"), ); expect(result.verdict).toBe(DENY); }); it("denies cat > with tilde path", async () => { - const result = await denyWritesOutsideProject.handler( + const result = await run( bash("cat > ~/other-project/foo.ts << 'EOF'\ncontent\nEOF"), ); expect(result.verdict).toBe(DENY); }); it("denies redirect to tilde path outside project", async () => { - const result = await denyWritesOutsideProject.handler( - bash("echo hi > ~/.bashrc"), - ); + const result = await run(bash("echo hi > ~/.bashrc")); expect(result.verdict).toBe(DENY); }); it("denies tee with tilde path", async () => { - const result = await denyWritesOutsideProject.handler( - bash("echo hi | tee ~/evil.txt"), - ); + const result = await run(bash("echo hi | tee ~/evil.txt")); expect(result.verdict).toBe(DENY); }); it("denies redirect with relative path outside project", async () => { - const result = await denyWritesOutsideProject.handler( - bash("echo hi > ../other-project/foo.ts"), - ); + const result = await run(bash("echo hi > ../other-project/foo.ts")); expect(result.verdict).toBe(DENY); }); it("allows redirect with relative path inside project", async () => { - const result = await denyWritesOutsideProject.handler( - bash("echo hi > ./src/foo.ts"), - ); + const result = await run(bash("echo hi > ./src/foo.ts")); expect(result.verdict).toBe(NEXT); }); }); describe("denies write commands (cp/mv/install) outside project", () => { it("denies cp to /tmp", async () => { - const result = await denyWritesOutsideProject.handler( - bash("cp src/file.ts /tmp/file.ts"), - ); + const result = await run(bash("cp src/file.ts /tmp/file.ts")); expect(result.verdict).toBe(DENY); - expect(result.reason).toContain("Use ./tmp/ within your project instead"); + expect((result as any).reason).toContain("Use ./tmp/ within your project instead"); }); it("denies cp in && chain to /tmp", async () => { - const result = await denyWritesOutsideProject.handler( + const result = await run( bash("cp /home/user/project/a /tmp/a-backup && cp /home/user/project/b /tmp/b-backup"), ); expect(result.verdict).toBe(DENY); }); it("denies mv to outside project", async () => { - const result = await denyWritesOutsideProject.handler( - bash("mv src/old.ts /home/user/other/old.ts"), - ); + const result = await run(bash("mv src/old.ts /home/user/other/old.ts")); expect(result.verdict).toBe(DENY); }); it("denies install to outside project", async () => { - const result = await denyWritesOutsideProject.handler( - bash("install -D src/bin /usr/local/bin/tool"), - ); + const result = await run(bash("install -D src/bin /usr/local/bin/tool")); expect(result.verdict).toBe(DENY); }); it("denies cp to tilde path", async () => { - const result = await denyWritesOutsideProject.handler( - bash("cp src/file.ts ~/backup.ts"), - ); + const result = await run(bash("cp src/file.ts ~/backup.ts")); expect(result.verdict).toBe(DENY); }); it("allows cp within project", async () => { - const result = await denyWritesOutsideProject.handler( - bash("cp src/a.ts src/b.ts"), - ); + const result = await run(bash("cp src/a.ts src/b.ts")); expect(result.verdict).toBe(NEXT); }); it("allows cp to relative path in project", async () => { - const result = await denyWritesOutsideProject.handler( - bash("cp src/a.ts ./tmp/backup.ts"), - ); + const result = await run(bash("cp src/a.ts ./tmp/backup.ts")); expect(result.verdict).toBe(NEXT); }); it("passes through cp with no projectRoot", async () => { - const result = await denyWritesOutsideProject.handler( - bash("cp src/a.ts /tmp/a.ts", null), - ); + const result = await run(bash("cp src/a.ts /tmp/a.ts", null)); expect(result.verdict).toBe(NEXT); }); }); @@ -244,7 +223,7 @@ describe("deny-writes-outside-project", () => { args: { file_path: "/shared/lib/utils.ts" }, context: { cwd: "/home/user/project", env: {}, projectRoot: "/home/user/project", additionalDirs: ["/shared/lib"] }, }; - const result = await denyWritesOutsideProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); @@ -254,34 +233,34 @@ describe("deny-writes-outside-project", () => { args: { file_path: "/etc/passwd" }, context: { cwd: "/home/user/project", env: {}, projectRoot: "/home/user/project", additionalDirs: ["/shared/lib"] }, }; - const result = await denyWritesOutsideProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(DENY); }); }); - describe("allows safe write targets", () => { - it("allows redirect to /dev/null", async () => { - const result = await denyWritesOutsideProject.handler(bash("cat foo 2>/dev/null")); - expect(result.verdict).toBe(ALLOW); + describe("safe write targets pass through", () => { + it("passes through redirect to /dev/null", async () => { + const result = await run(bash("cat foo 2>/dev/null")); + expect(result.verdict).toBe(NEXT); }); - it("allows redirect to /dev/stderr", async () => { - const result = await denyWritesOutsideProject.handler(bash("echo err > /dev/stderr")); - expect(result.verdict).toBe(ALLOW); + it("passes through redirect to /dev/stderr", async () => { + const result = await run(bash("echo err > /dev/stderr")); + expect(result.verdict).toBe(NEXT); }); - it("allows redirect to /dev/stdout", async () => { - const result = await denyWritesOutsideProject.handler(bash("echo ok > /dev/stdout")); - expect(result.verdict).toBe(ALLOW); + it("passes through redirect to /dev/stdout", async () => { + const result = await run(bash("echo ok > /dev/stdout")); + expect(result.verdict).toBe(NEXT); }); - it("allows tee to /dev/null", async () => { - const result = await denyWritesOutsideProject.handler(bash("echo hi | tee /dev/null")); - expect(result.verdict).toBe(ALLOW); + it("passes through tee to /dev/null", async () => { + const result = await run(bash("echo hi | tee /dev/null")); + expect(result.verdict).toBe(NEXT); }); it("denies when mix of safe and outside targets", async () => { - const result = await denyWritesOutsideProject.handler( + const result = await run( bash("echo hi > /dev/null\necho hi > /etc/passwd"), ); expect(result.verdict).toBe(DENY); diff --git a/policies/tests/parse-bash-ast.test.ts b/policies/tests/parse-bash-ast.test.ts index 8f29e51..8ff79ad 100644 --- a/policies/tests/parse-bash-ast.test.ts +++ b/policies/tests/parse-bash-ast.test.ts @@ -348,6 +348,26 @@ describe("isSafeFilter", () => { ["stat", "foo.txt"], ["du", "-sh", "."], ["diff", "a.txt", "b.txt"], + ["rg", "pattern"], + ["rg", "-i", "pattern"], + ["rg", "-oP", "(\\w+):(\\d+)"], + ["rg", "-oP", "(\\w+):(\\d+)", "--replace", "$1=$2"], + ["rg", "-r", "$1", "pattern"], + ["rg", "--replace", "$1", "(\\w+)"], + ["sd", "find", "replace"], + ["sd", "-p", "find", "replace"], + ["sd", "--preview", "find", "replace"], + ["sd", "-s", "literal", "literal"], + ["choose", "0"], + ["choose", "0", "2:4", "-1"], + ["choose", "-f", ":", "0", "2"], + ["xq", "."], + ["xq", "-r", ".items[]"], + ["xq", "-x", "/root/foo"], + ["xq", "-q", "div.item"], + ["htmlq", ".item"], + ["htmlq", "--text", "h1"], + ["htmlq", "-a", "href", "a"], ]; for (const tokens of safe) { @@ -366,6 +386,46 @@ describe("isSafeFilter", () => { ["sort", "-o", "outfile"], ["sort", "--output", "file"], ["uniq", "input", "output"], + ["rg", "--pre", "evil.sh", "pattern"], + ["rg", "--preprocessor", "evil.sh", "pattern"], + ["rg", "--pre=evil.sh", "pattern"], + ["rg", "--preprocessor=evil.sh", "pattern"], + ["rg", "--pre-glob", "*.log", "pattern"], + ["rg", "--hostname-bin", "uname", "pattern"], + ["rg", "--hostname-bin=uname", "pattern"], + ["rg", "-z", "pattern"], + ["rg", "--search-zip", "pattern"], + ["rg", "-f", "patterns.txt"], + ["rg", "--file", "patterns.txt"], + ["rg", "--file=patterns.txt"], + ["rg", "--ignore-file", "ig.txt"], + ["rg", "--ignore-file=ig.txt"], + ["rg", "pattern", "/etc/passwd"], + ["rg", "pattern", "~/.ssh/id_rsa"], + ["sd", "find", "replace", "file.txt"], + ["sd", "find", "replace", "a.txt", "b.txt"], + ["sd", "find"], + ["sd"], + ["choose", "-i", "/etc/passwd", "0"], + ["choose", "--input", "/etc/passwd", "0"], + ["choose", "--input=/etc/passwd", "0"], + ["xq", "-i", "config.xml"], + ["xq", "--in-place", "config.xml"], + ["xq", "--rawfile", "name", "/etc/passwd"], + ["xq", "--slurpfile", "name", "data.json"], + ["xq", "-f", "filter.jq"], + ["xq", "--from-file", "filter.jq"], + ["xq", "-L", "/lib"], + ["xq", "--library-path", "/lib"], + ["xq", "/etc/passwd"], + ["xq", "~/secrets.xml"], + ["xq", "filter", "data.xml"], + ["htmlq", "-f", "/etc/passwd", ".item"], + ["htmlq", "--filename", "/etc/passwd"], + ["htmlq", "--filename=/etc/passwd"], + ["htmlq", "-o", "/tmp/out.html"], + ["htmlq", "--output", "/tmp/out.html"], + ["htmlq", "--output=/tmp/out.html"], ]; for (const tokens of unsafe) { diff --git a/policies/tests/redirect-plans-to-project.test.ts b/policies/tests/redirect-plans-to-project.test.ts index 4da8528..891458b 100644 --- a/policies/tests/redirect-plans-to-project.test.ts +++ b/policies/tests/redirect-plans-to-project.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import redirectPlansToProject from "../redirect-plans-to-project"; +const run = adaptHandler(redirectPlansToProject.action!, redirectPlansToProject.handler as any); + const PROJECT = "/home/user/project"; function write(filePath: string, projectRoot: string | null = PROJECT): ToolCall { @@ -23,7 +25,7 @@ function bash(command: string, projectRoot: string | null = PROJECT): ToolCall { describe("redirect-plans-to-project", () => { describe("denies writes to global plans directory", () => { it("denies Write to ~/.claude/plans/", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( write("/home/user/.claude/plans/my-plan.md"), ); expect(result.verdict).toBe(DENY); @@ -36,12 +38,12 @@ describe("redirect-plans-to-project", () => { args: { file_path: "/home/user/.claude/plans/my-plan.md" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await redirectPlansToProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(DENY); }); it("denies Write to any user's .claude/plans", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( write("/Users/bryce/.claude/plans/polymorphic-skipping-honey.md"), ); expect(result.verdict).toBe(DENY); @@ -50,7 +52,7 @@ describe("redirect-plans-to-project", () => { describe("denies Bash redirects to global plans directory", () => { it("denies cat > ~/.claude/plans/file", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( bash("cat > /home/user/.claude/plans/plan.md"), ); expect(result.verdict).toBe(DENY); @@ -58,21 +60,21 @@ describe("redirect-plans-to-project", () => { }); it("denies heredoc redirect to plans dir", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( bash("cat > /home/user/.claude/plans/evil.md << 'EOF'\nplan content\nEOF"), ); expect(result.verdict).toBe(DENY); }); it("denies mkdir && cat > plans dir", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( bash("mkdir -p /home/user/.claude/plans && cat > /home/user/.claude/plans/plan.md"), ); expect(result.verdict).toBe(DENY); }); it("denies tee to plans dir", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( bash("echo content | tee /home/user/.claude/plans/plan.md"), ); expect(result.verdict).toBe(DENY); @@ -81,14 +83,14 @@ describe("redirect-plans-to-project", () => { describe("allows writes to project docs folder", () => { it("allows Write to project docs/", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( write("/home/user/project/docs/plan.md"), ); expect(result.verdict).toBe(NEXT); }); it("allows Bash redirect to project docs/", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( bash("cat > /home/user/project/docs/plan.md"), ); expect(result.verdict).toBe(NEXT); @@ -102,17 +104,17 @@ describe("redirect-plans-to-project", () => { args: { file_path: "/home/user/.claude/plans/plan.md" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await redirectPlansToProject.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); it("passes through Bash with no redirects", async () => { - const result = await redirectPlansToProject.handler(bash("ls -la")); + const result = await run(bash("ls -la")); expect(result.verdict).toBe(NEXT); }); it("passes through when no projectRoot", async () => { - const result = await redirectPlansToProject.handler( + const result = await run( write("/home/user/.claude/plans/plan.md", null), ); expect(result.verdict).toBe(NEXT); diff --git a/policies/tests/redirect-python-json-to-fx.test.ts b/policies/tests/redirect-python-json-to-fx.test.ts index 00918de..038be74 100644 --- a/policies/tests/redirect-python-json-to-fx.test.ts +++ b/policies/tests/redirect-python-json-to-fx.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; import redirectPythonJsonToFx from "../redirect-python-json-to-fx"; +const run = adaptHandler(redirectPythonJsonToFx.action!, redirectPythonJsonToFx.handler as any); + const PROJECT = "/home/user/project"; function bash(command: string, description?: string): ToolCall { @@ -25,7 +27,7 @@ describe("redirect-python-json-to-fx", () => { for (const cmd of denied) { it(`denies: ${cmd}`, async () => { - const result = await redirectPythonJsonToFx.handler(bash(cmd)); + const result = await run(bash(cmd)); expect(result.verdict).toBe(DENY); }); } @@ -69,7 +71,7 @@ describe("redirect-python-json-to-fx", () => { for (const { cmd, desc } of cases) { it(`denies: "${desc}"`, async () => { - const result = await redirectPythonJsonToFx.handler(bash(cmd, desc)); + const result = await run(bash(cmd, desc)); expect(result.verdict).toBe(DENY); }); } @@ -101,7 +103,7 @@ describe("redirect-python-json-to-fx", () => { for (const { cmd, desc } of allowed) { it(`passes through: "${desc}"`, async () => { - const result = await redirectPythonJsonToFx.handler(bash(cmd, desc)); + const result = await run(bash(cmd, desc)); expect(result.verdict).toBe(NEXT); }); } @@ -109,7 +111,7 @@ describe("redirect-python-json-to-fx", () => { describe("passes through when no description", () => { it("python3 -c with json but no description", async () => { - const result = await redirectPythonJsonToFx.handler( + const result = await run( bash('python3 -c "import json; print(json.load(open(\'x.json\')))"'), ); expect(result.verdict).toBe(NEXT); @@ -126,7 +128,7 @@ describe("redirect-python-json-to-fx", () => { for (const { cmd, desc } of ignored) { it(`passes through: ${cmd}`, async () => { - const result = await redirectPythonJsonToFx.handler(bash(cmd, desc)); + const result = await run(bash(cmd, desc)); expect(result.verdict).toBe(NEXT); }); } @@ -138,7 +140,7 @@ describe("redirect-python-json-to-fx", () => { args: { file_path: "/some/file.json" }, context: { cwd: PROJECT, env: {}, projectRoot: PROJECT }, }; - const result = await redirectPythonJsonToFx.handler(call); + const result = await run(call); expect(result.verdict).toBe(NEXT); }); }); diff --git a/policies/tests/redirect-trivial-wrangler-to-read.test.ts b/policies/tests/redirect-trivial-wrangler-to-read.test.ts new file mode 100644 index 0000000..3a8ea47 --- /dev/null +++ b/policies/tests/redirect-trivial-wrangler-to-read.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { adaptHandler, DENY, NEXT, type ToolCall } from "@brycehanscomb/toolgate"; +import redirectTrivialWranglerToRead from "../redirect-trivial-wrangler-to-read"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const run = adaptHandler( + redirectTrivialWranglerToRead.action!, + redirectTrivialWranglerToRead.handler as any, +); + +let tmpDir: string; +let smallPath: string; +let bigPath: string; +let dirPath: string; + +beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), "toolgate-trivial-wrangler-")); + smallPath = join(tmpDir, "small.json"); + bigPath = join(tmpDir, "big.json"); + dirPath = join(tmpDir, "subdir"); + writeFileSync(smallPath, '{"a":1,"b":2}'); // ~13 bytes + writeFileSync(bigPath, "x".repeat(20 * 1024)); // 20 KB + mkdirSync(dirPath); +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function bash(command: string): ToolCall { + return { + tool: "Bash", + args: { command }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; +} + +describe("redirect-trivial-wrangler-to-read", () => { + describe("denies trivial wranglers on small files (steer to Read)", () => { + it("jq . ", async () => { + const r = await run(bash(`jq . ${smallPath}`)); + expect(r.verdict).toBe(DENY); + expect(r.reason).toMatch(/Read/); + }); + it("jq (no filter — defaults to identity)", async () => { + const r = await run(bash(`jq ${smallPath}`)); + expect(r.verdict).toBe(DENY); + }); + it("fx .", async () => { + const r = await run(bash(`fx ${smallPath} .`)); + expect(r.verdict).toBe(DENY); + }); + it("fx (no filter)", async () => { + const r = await run(bash(`fx ${smallPath}`)); + expect(r.verdict).toBe(DENY); + }); + it("yq . ", async () => { + const r = await run(bash(`yq . ${smallPath}`)); + expect(r.verdict).toBe(DENY); + }); + it("gron (gron has no filter — always trivial)", async () => { + const r = await run(bash(`gron ${smallPath}`)); + expect(r.verdict).toBe(DENY); + }); + it("jq -r . (flag does not change triviality)", async () => { + const r = await run(bash(`jq -r . ${smallPath}`)); + expect(r.verdict).toBe(DENY); + }); + it("fx _ (underscore is also identity)", async () => { + const r = await run(bash(`fx ${smallPath} _`)); + expect(r.verdict).toBe(DENY); + }); + }); + + describe("passes through real filters even on small files", () => { + it("jq '.users' ", async () => { + const r = await run(bash(`jq '.users' ${smallPath}`)); + expect(r.verdict).toBe(NEXT); + }); + it("fx '.a'", async () => { + const r = await run(bash(`fx ${smallPath} '.a'`)); + expect(r.verdict).toBe(NEXT); + }); + it("jq '.users[].name' ", async () => { + const r = await run(bash(`jq '.users[].name' ${smallPath}`)); + expect(r.verdict).toBe(NEXT); + }); + }); + + describe("passes through when file is too big", () => { + it("jq . (20KB)", async () => { + const r = await run(bash(`jq . ${bigPath}`)); + expect(r.verdict).toBe(NEXT); + }); + it("gron ", async () => { + const r = await run(bash(`gron ${bigPath}`)); + expect(r.verdict).toBe(NEXT); + }); + }); + + describe("passes through when file doesn't exist", () => { + it("jq . ", async () => { + const r = await run(bash(`jq . ${join(tmpDir, "does-not-exist.json")}`)); + expect(r.verdict).toBe(NEXT); + }); + it("jq . (no positional at all)", async () => { + const r = await run(bash(`jq .`)); + expect(r.verdict).toBe(NEXT); + }); + }); + + describe("ignores directories (not regular files)", () => { + it("jq . ", async () => { + const r = await run(bash(`jq . ${dirPath}`)); + expect(r.verdict).toBe(NEXT); + }); + }); + + describe("doesn't fire on piped invocations (handled by deny-wrangler-pipes)", () => { + it("cat | jq .", async () => { + const r = await run(bash(`cat ${smallPath} | jq .`)); + expect(r.verdict).toBe(NEXT); + }); + }); + + describe("ignores non-wrangler commands", () => { + it("cat ", async () => { + const r = await run(bash(`cat ${smallPath}`)); + expect(r.verdict).toBe(NEXT); + }); + it("ls ", async () => { + const r = await run(bash(`ls ${smallPath}`)); + expect(r.verdict).toBe(NEXT); + }); + }); + + it("passes through non-Bash tools", async () => { + const call: ToolCall = { + tool: "Read", + args: { file_path: smallPath }, + context: { cwd: "/tmp", env: {}, projectRoot: null }, + }; + const r = await run(call); + expect(r.verdict).toBe(NEXT); + }); +}); diff --git a/src/adapter.ts b/src/adapter.ts new file mode 100644 index 0000000..ece2128 --- /dev/null +++ b/src/adapter.ts @@ -0,0 +1,26 @@ +import type { ToolCall, VerdictResult, PolicyHandler, Middleware } from "./types"; +import { allow, deny, next } from "./verdicts"; + +export function adaptHandler( + action: "deny" | "allow", + handler: PolicyHandler, +): Middleware { + return async (call: ToolCall): Promise => { + const result = await handler(call); + + // Falsy or void → pass through + if (result === undefined || result === null || result === false) { + return next(); + } + + if (action === "allow") { + return allow(); + } + + // action === "deny" + if (typeof result === "string") { + return deny(result); + } + return deny(); + }; +} diff --git a/src/index.ts b/src/index.ts index cf88f73..c18ccf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { ALLOW, DENY, NEXT, allow, deny, next, isVerdictResult } from './verdicts' -export type { ToolCall, CallContext, VerdictResult, Middleware, Policy } from './types' +export type { ToolCall, CallContext, VerdictResult, Middleware, Policy, PolicyHandler } from './types' export { definePolicy, runPolicy, runPolicyWithTrace } from './policy' export type { TracedResult } from './policy' export { isWithinProject, loadAdditionalDirs } from './project-dirs' +export { adaptHandler } from './adapter' diff --git a/src/policy.ts b/src/policy.ts index ae2d692..3ce43ff 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -1,5 +1,6 @@ import type { Policy, ToolCall, VerdictResult } from './types' import { isVerdictResult, next, NEXT } from './verdicts' +import { adaptHandler } from './adapter' export function definePolicy(policies: Policy[]): Policy[] { return policies @@ -7,7 +8,7 @@ export function definePolicy(policies: Policy[]): Policy[] { export interface TracedResult { result: VerdictResult - /** Index of the policy that returned the verdict, or -1 if all returned next() */ + /** Index of the policy in the original input array, or -1 if all passed */ index: number /** Name of the policy, if available */ name: string | null @@ -21,19 +22,43 @@ export async function runPolicy(policies: Policy[], call: ToolCall): Promise { + // Partition into deny-first, allow-second, preserving relative order within each group. + // Legacy policies (no action) run in their original position among allow policies. + const denyPolicies: { policy: Policy; originalIndex: number }[] = [] + const allowPolicies: { policy: Policy; originalIndex: number }[] = [] + for (let i = 0; i < policies.length; i++) { - const policy = policies[i] - const result = await policy.handler(call) + const p = policies[i] + if (p.action === 'deny') { + denyPolicies.push({ policy: p, originalIndex: i }) + } else { + allowPolicies.push({ policy: p, originalIndex: i }) + } + } + + const ordered = [...denyPolicies, ...allowPolicies] + + for (const { policy, originalIndex } of ordered) { + let result: VerdictResult + + if (policy.action) { + // New-style policy: adapt simplified return values to VerdictResult + const adapted = adaptHandler(policy.action, policy.handler as any) + result = await adapted(call) + } else { + // Legacy policy (no action): handler returns VerdictResult directly + result = await (policy.handler as any)(call) + } if (!isVerdictResult(result)) { throw new Error( - `toolgate: policy[${i}] "${policy.name}" returned invalid verdict: ${JSON.stringify(result)}\n` + + `toolgate: policy[${originalIndex}] "${policy.name}" returned invalid verdict: ${JSON.stringify(result)}\n` + ` Every policy handler must return allow(), deny(), or next().` ) } if (result.verdict !== NEXT) { - return { result, index: i, name: policy.name, description: policy.description } + return { result, index: originalIndex, name: policy.name, description: policy.description } } } diff --git a/src/tests/adapter.test.ts b/src/tests/adapter.test.ts new file mode 100644 index 0000000..0a66b0b --- /dev/null +++ b/src/tests/adapter.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; +import { adaptHandler } from "../adapter"; +import { ALLOW, DENY, NEXT } from "../verdicts"; + +describe("adaptHandler", () => { + describe("allow action", () => { + it("converts true to ALLOW", async () => { + const handler = adaptHandler("allow", async () => true); + const result = await handler({} as any); + expect(result.verdict).toBe(ALLOW); + }); + + it("converts void/undefined to NEXT", async () => { + const handler = adaptHandler("allow", async () => {}); + const result = await handler({} as any); + expect(result.verdict).toBe(NEXT); + }); + + it("converts false to NEXT", async () => { + const handler = adaptHandler("allow", async () => false); + const result = await handler({} as any); + expect(result.verdict).toBe(NEXT); + }); + + it("converts string to ALLOW (truthy value)", async () => { + const handler = adaptHandler("allow", async () => "some reason"); + const result = await handler({} as any); + expect(result.verdict).toBe(ALLOW); + }); + }); + + describe("deny action", () => { + it("converts true to DENY without reason", async () => { + const handler = adaptHandler("deny", async () => true); + const result = await handler({} as any); + expect(result.verdict).toBe(DENY); + expect("reason" in result).toBe(false); + }); + + it("converts string to DENY with reason", async () => { + const handler = adaptHandler("deny", async () => "not allowed here"); + const result = await handler({} as any); + expect(result.verdict).toBe(DENY); + expect((result as any).reason).toBe("not allowed here"); + }); + + it("converts void/undefined to NEXT", async () => { + const handler = adaptHandler("deny", async () => {}); + const result = await handler({} as any); + expect(result.verdict).toBe(NEXT); + }); + + it("converts false to NEXT", async () => { + const handler = adaptHandler("deny", async () => false); + const result = await handler({} as any); + expect(result.verdict).toBe(NEXT); + }); + }); +}); diff --git a/src/tests/policy-action-order.test.ts b/src/tests/policy-action-order.test.ts new file mode 100644 index 0000000..7a55064 --- /dev/null +++ b/src/tests/policy-action-order.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "bun:test"; +import { runPolicy, runPolicyWithTrace } from "../policy"; +import { ALLOW, DENY, NEXT } from "../verdicts"; +import type { Policy, ToolCall } from "../types"; + +const call: ToolCall = { + tool: "Bash", + args: { command: "echo hi" }, + context: { cwd: "/tmp", env: {}, projectRoot: "/tmp", additionalDirs: [] }, +}; + +describe("runPolicy action ordering", () => { + it("runs deny policies before allow policies regardless of array order", async () => { + const log: string[] = []; + + const allowFirst: Policy = { + name: "allow-first", + description: "", + action: "allow", + handler: async () => { log.push("allow"); return true; }, + }; + const denySecond: Policy = { + name: "deny-second", + description: "", + action: "deny", + handler: async () => { log.push("deny"); }, // pass through + }; + + // allow is listed first, but deny should run first + const result = await runPolicy([allowFirst, denySecond], call); + expect(log).toEqual(["deny", "allow"]); + expect(result.verdict).toBe(ALLOW); + }); + + it("deny policy short-circuits before allow policies run", async () => { + const log: string[] = []; + + const allowPolicy: Policy = { + name: "allow-it", + description: "", + action: "allow", + handler: async () => { log.push("allow"); return true; }, + }; + const denyPolicy: Policy = { + name: "deny-it", + description: "", + action: "deny", + handler: async () => { log.push("deny"); return "blocked"; }, + }; + + const result = await runPolicy([allowPolicy, denyPolicy], call); + expect(log).toEqual(["deny"]); + expect(result.verdict).toBe(DENY); + }); + + it("preserves relative order within same action type", async () => { + const log: string[] = []; + + const deny1: Policy = { + name: "deny-1", + description: "", + action: "deny", + handler: async () => { log.push("deny-1"); }, + }; + const deny2: Policy = { + name: "deny-2", + description: "", + action: "deny", + handler: async () => { log.push("deny-2"); }, + }; + const allow1: Policy = { + name: "allow-1", + description: "", + action: "allow", + handler: async () => { log.push("allow-1"); return true; }, + }; + + await runPolicy([allow1, deny2, deny1], call); + // deny policies run first in original relative order, then allow + expect(log).toEqual(["deny-2", "deny-1", "allow-1"]); + }); + + it("returns NEXT when no policy activates", async () => { + const passThrough: Policy = { + name: "noop", + description: "", + action: "allow", + handler: async () => {}, + }; + + const result = await runPolicy([passThrough], call); + expect(result.verdict).toBe(NEXT); + }); + + it("trace returns correct policy name on deny", async () => { + const denyPolicy: Policy = { + name: "the-blocker", + description: "blocks stuff", + action: "deny", + handler: async () => "nope", + }; + + const { result, name } = await runPolicyWithTrace([denyPolicy], call); + expect(result.verdict).toBe(DENY); + expect(name).toBe("the-blocker"); + }); +}); diff --git a/src/types.ts b/src/types.ts index b74f7b8..9f17d3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,10 +18,15 @@ export type VerdictResult = | { verdict: typeof DENY; reason?: string } | { verdict: typeof NEXT }; +/** @internal Used by the engine to run adapted handlers */ export type Middleware = (call: ToolCall) => Promise; +/** New simplified handler signature for policy authors */ +export type PolicyHandler = (call: ToolCall) => Promise; + export interface Policy { name: string; description: string; - handler: Middleware; + action?: "deny" | "allow"; + handler: PolicyHandler | Middleware; } diff --git a/toolgate.config.ts b/toolgate.config.ts index 0019283..240a82e 100644 --- a/toolgate.config.ts +++ b/toolgate.config.ts @@ -1,6 +1,5 @@ import { homedir } from "os"; import { definePolicy } from "./src/index"; -import { allow, next } from "./src/verdicts"; const CLAUDE_DIR = `${homedir()}/.claude`; @@ -17,25 +16,26 @@ export default definePolicy([ { name: "Allow CRUD in ~/.claude", description: "Permits Read/Write/Edit/Glob/Grep on paths within ~/.claude", + action: "allow", handler: async (call) => { - if (!FILE_TOOLS.has(call.tool) && !PATH_TOOLS.has(call.tool)) return next(); + if (!FILE_TOOLS.has(call.tool) && !PATH_TOOLS.has(call.tool)) return; const path = getPath(call.tool, call.args); - if (!path) return next(); + if (!path) return; if (path === CLAUDE_DIR || path.startsWith(CLAUDE_DIR + "/")) { - return allow(); + return true; } - return next(); }, }, { name: "Allow claude-code-guide agent", description: "Permits the claude-code-guide read-only research agent", + action: "allow", handler: async (call) => { - if (call.tool !== "Agent") return next(); - if (call.args.subagent_type !== "claude-code-guide") return next(); - return allow(); + if (call.tool !== "Agent") return; + if (call.args.subagent_type !== "claude-code-guide") return; + return true; }, }, ]);