diff --git a/README.md b/README.md index 622f0b76..3cfc3b51 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,17 @@ > - ⚠️ **Autonomous Execution** - Makes decisions and executes actions independently > > **ONLY use in isolated environments** (VMs, Docker containers) where AI agents can have unrestricted access. **NOT SAFE** for personal computers, production servers, or systems with sensitive data. +> +> πŸ›‘οΈ **Opt-in read-only mode:** For planning-only tasks you can run the agent +> with `--read-only` (or `--disable-tools bash,write,edit`), a native, +> enforceable mode that disables all filesystem-mutating and shell tools. See +> [TOOLS.md β†’ Read-Only / Planning Mode](TOOLS.md#read-only--planning-mode). +> +> πŸ” **Opt-in permission system:** For finer-grained control there is a native, +> JSON-driven permission system (`--permission-mode auto|plan|readonly|ask` and +> an OpenCode-compatible `--permission ''` override) with per-command +> approval over stdin/stdout and **no TUI**. Default stays full auto. See +> [docs/permissions.md](docs/permissions.md). ## Implementations diff --git a/TOOLS.md b/TOOLS.md index 7bf6d6d6..56b06a29 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -105,6 +105,76 @@ Fetches content from a specified URL and processes it using an AI model. **Status:** βœ… Fully supported and tested **Test:** [tests/webfetch.tools.test.js](tests/webfetch.tools.test.js) +## Read-Only / Planning Mode + +By default all tools are enabled and the agent runs with full, unrestricted +access. For planning-only tasks (or to enforce a per-command approval UX in a +parent process such as +[`agent-commander`](https://github.com/link-assistant/agent-commander)), the +agent supports a **native, enforceable read-only mode**. + +### `--read-only` + +Disables every filesystem-mutating and shell tool so the agent can only read, +search and plan: + +| Tool | read-only | +| ----------- | --------- | +| `bash` | ❌ disabled | +| `edit` | ❌ disabled | +| `write` | ❌ disabled | +| `multiedit` | ❌ disabled | +| `patch` | ❌ disabled | +| everything else (`read`, `list`, `glob`, `grep`, `websearch`, `codesearch`, `webfetch`, `todo`, `batch`, `task`) | βœ… enabled | + +```bash +echo "Summarize this project" | agent --read-only +``` + +Can also be enabled with the `LINK_ASSISTANT_AGENT_READ_ONLY=true` environment +variable. + +The restriction is enforced where tools are exposed to the model **and** when a +tool is invoked indirectly through the `batch` tool, so it cannot be bypassed by +the model. + +### `--disable-tools ` + +Disable an explicit, comma-separated set of tools (in addition to or instead of +`--read-only`): + +```bash +echo "hi" | agent --disable-tools bash,write,edit +``` + +Can also be set with `LINK_ASSISTANT_AGENT_DISABLE_TOOLS=bash,write,edit`. + +### Fine-grained permission system (`--permission-mode` / `--permission`) + +`--read-only` / `--disable-tools` remove tools entirely (the hard layer). For +finer control β€” read-only **planning** that still allows safe shell commands, or +**per-command approval** β€” the agent ships a native, JSON-driven permission +system ported from OpenCode, with **no TUI**: + +```bash +# Deny edits, allow read-only shell, ask before anything else (planning): +agent --permission-mode plan --input-format stream-json + +# Hard read-only that still allows read-only shell commands, never asks: +agent --permission-mode readonly -p "summarize the repo layout" + +# Approve every mutating tool over JSON (stdin/stdout): +agent --permission-mode ask --input-format stream-json + +# OpenCode-compatible fine-grained override, merged on top of the mode: +agent --permission '{"edit":"ask","bash":{"git push*":"ask","*":"allow"}}' +``` + +The default mode is `auto` (full autonomy, never asks β€” unchanged behavior). +Approvals are exchanged as `permission_request` / `permission_response` JSON +frames. See [docs/permissions.md](docs/permissions.md) for the full protocol, +every mode, the JSON shapes, environment variables, and worked examples. + ## Testing ### Run All Tool Tests diff --git a/docs/case-studies/issue-271/README.md b/docs/case-studies/issue-271/README.md new file mode 100644 index 00000000..d4734608 --- /dev/null +++ b/docs/case-studies/issue-271/README.md @@ -0,0 +1,157 @@ +# Case Study: Native Enforceable Permission System (read-only / plan / per-command approval) + +**Issue:** [#271](https://github.com/link-assistant/agent/issues/271) +**PR:** [#272](https://github.com/link-assistant/agent/pull/272) +**Date:** 2026-06-17 + +## Problem Statement + +The Agent CLI advertised itself as "100% UNSAFE AND AUTONOMOUS … No Permissions +System … No Safety Guardrails". Because there was no native permission system, +`link-assistant/agent-commander` listed the `agent` tool's read-only mode as +**"❌ not enforceable"** and rejected `agent-commander start-agent --tool agent +--read-only` before launch β€” every other tool (`claude`, `codex`, `opencode`, +`qwen`, `gemini`) maps `--read-only` to a native restriction. + +This blocked downstream consumers (e.g. the Formal-AI desktop app, +link-assistant/formal-ai#511) that want to run `@link-assistant/agent` for +**read-only / planning** work with **per-command approval**, and could not rely +on the CLI to enforce no-write / no-shell-mutation. + +The maintainer's directive ([issue comment +4730457589](https://github.com/link-assistant/agent/issues/271#issuecomment-4730457589)) +sharpened the scope: + +> We need to add permissions system from OpenCode back (in the most similar way +> possible), but by default our Agent CLI should use full auto-mode, and if we +> need plan mode or readonly, and approve each command option, we should use CLI +> options. Make sure everything is fully controllable from JSON (input and +> output) with detailed docs on how to send approval JSON. We still will not +> integrate any TUI … make sure we fully implement it in both JavaScript and +> Rust. + +## Requirements + +Each requirement extracted from the issue body and the maintainer comment, with +how it is resolved in this PR: + +| # | Requirement (source) | Resolution | +|---|----------------------|------------| +| R1 | Re-add OpenCode's permission system "in the most similar way possible" (comment) | Ported OpenCode's `Permission` namespace (`ask`/`respond`/`RejectedError`, `permission.updated`/`permission.replied` bus events, `once`/`always`/`reject` responses, per-tool glob rules with "last matching rule wins") to `js/src/permission/index.ts` and `rust/src/permission.rs`. | +| R2 | Default behavior must remain **full auto-mode** (comment) | Default `--permission-mode auto` resolves every action to `allow`. `Permission.bashEnforced()` short-circuits the tree-sitter parse, so the default path has zero added overhead and no behavior change. | +| R3 | Plan mode, readonly mode, and approve-each-command via **CLI options** (comment + issue) | `--permission-mode {auto,plan,readonly,ask}` plus an OpenCode-compatible `--permission ''` override. `plan` denies edits + allows read-only shell + asks otherwise; `readonly` denies all mutations (never asks); `ask` prompts before every mutating tool. | +| R4 | Everything **fully controllable from JSON** (input and output) (comment) | Output: a `permission_request` JSON event is emitted when a tool needs approval. Input: a `permission_response` JSON frame (`{"type":"permission_response","permissionID":"…","response":"once"\|"always"\|"reject"}`) resolves it, accepted in both text and `stream-json` input modes. No TUI. | +| R5 | **Detailed docs** on how to send approval JSON (comment) | [`docs/permissions.md`](../../permissions.md) documents the protocol, every mode, the JSON shapes, environment variables, and worked end-to-end examples. | +| R6 | Fail clearly if the mode cannot be honored (issue) | A denied tool throws `RejectedError` with an actionable message naming the tool and active mode; combined with the hard `--read-only` / `--disable-tools` tool-removal layer the model never even sees disabled tools. | +| R7 | Expressible through OpenCode-compatible permission/deny rules (issue) | `--permission` accepts the OpenCode `{edit, bash, webfetch}` shape, where `bash` is a string or a `{pattern: action}` map evaluated with the same structured wildcard matcher. | +| R8 | Implement in **both JavaScript and Rust** (comment) | JS: full runtime enforcement in the agent loop + tools. Rust: `permission` module with the same policy/mode/override semantics, CLI flags, JSON event schemas, and unit tests (the Rust binary has no agent loop yet, so enforcement is policy-resolution + schema parity; this is documented honestly below). | +| R9 | Collect issue data into `./docs/case-studies/issue-271`, deep analysis, online research, requirement list, solution plans, existing components (comment) | This folder: `README.md` (analysis + per-requirement solution plans), `research-permissions-landscape.md` (sourced prior-art survey), `research-data.json` (machine-readable facts). | +| R10 | No TUI (comment) | No interactive terminal UI is added. Approvals are pure JSON over stdin/stdout. The vestigial OpenCode TUI `select()` in `cmd/run.ts` is left untouched and is not on the JSON path. | + +## Root Cause + +When `@link-assistant/agent` forked OpenCode, the entire `permission` namespace +and all tool-level permission checks were stripped out (each mutating tool was +reduced to a `// No restrictions` comment) to make the CLI fully autonomous. +That made read-only / planning impossible to enforce natively, which is exactly +what agent-commander's uniform `--read-only` flag requires. + +## Solution + +A native permission system modeled on OpenCode, but **JSON-first and TUI-free**, +defaulting to full auto so nothing changes for existing consumers. + +### Architecture + +1. **Policy core** (`js/src/permission/index.ts`, `rust/src/permission.rs`) + - `Mode` = `auto | plan | readonly | ask`; `Action` = `allow | ask | deny`. + - `modePolicy(mode)` produces a `{edit, bash, webfetch}` policy. `bash` is a + globβ†’action map; plan/readonly seed it from a read-only shell allowlist + (`cat`, `ls`, `grep`, `git diff/log/status/show`, …) with the destructive + variants (`find … -delete/-exec`, `sort -o`, redirection, command + substitution) falling back to ask/deny. + - `parseOverride(json)` parses the OpenCode-compatible `--permission` JSON; + `policy()` merges it on top of the mode (override wins, bash maps merge + key-by-key). + +2. **Ask / respond machinery** (ported from OpenCode) + - `ask()` resolves immediately if already approved (`always`), otherwise + registers a pending request, publishes `permission.updated`, and returns a + promise resolved by `respond()`. + - `respond({sessionID, permissionID, response})` resolves/rejects the pending + promise; `always` records a session-scoped approval that auto-clears other + covered pending requests. Disposing the instance rejects all pending asks. + +3. **Tool enforcement** (JS) + - `bash`: parses the command with tree-sitter, extracts each `command` node's + tokens, evaluates every node against the bash policy β€” `deny` throws, `ask` + patterns are batched into one request. Skipped entirely in `auto` mode. + - `edit` / `write` / `patch`: `Permission.check({type:'edit', …})`. + - `webfetch`: `Permission.check({type:'webfetch', …})`. + - `multiedit` routes through the `edit` tool, inheriting its check. + +4. **JSON I/O** (JS CLI) + - `event-handler.js` emits `permission_request` on `permission.updated`. + - `input-queue.js` parses `permission_response` frames in both text and + `stream-json` modes. + - `continuous-mode.js` routes `permission_response` straight to + `Permission.respond`, bypassing the in-flight-turn queue so a blocked turn + can be unblocked mid-flight. + +5. **Hard layer retained**: `--read-only` / `--disable-tools` still remove tools + from the model entirely (issue #271's first increment, PR #271). The new + permission system is the finer-grained, OpenCode-compatible layer on top. + +### Why ask-modes need streaming input + +`readonly` never asks (deny-only), so it works in any input mode. `plan` and +`ask` emit `permission_request` mid-turn and block until a `permission_response` +arrives, so they require the continuous/`stream-json` input mode (single-shot +`--prompt` would deadlock on the first ask). This is documented in +`docs/permissions.md`. + +## Alternatives Considered + +| Option | Tradeoff | +|--------|----------| +| Tool-removal only (`--disable-tools`, already shipped) | Enforceable and simple, but coarse: cannot allow read-only shell in plan mode, and offers no per-command approval UX. | +| Re-add OpenCode's TUI `select()` approval | Rejected by the maintainer ("we still will not integrate any TUI"). | +| Re-add OpenCode's `Plugin.trigger` hook for permissions | Dropped β€” the fork has no plugin host on the JSON path; approval is driven purely over stdin JSON. | +| Re-add OpenCode's `Filesystem.contains` path-containment guard for bash | Not re-added by default to preserve full autonomy; path scoping can be layered later via `--permission` rules. | +| Build a brand-new permission DSL | Rejected β€” the issue explicitly asks to reuse OpenCode "in the most similar way possible" for compatibility. | + +## Existing Components / Prior Art Evaluated + +See [`research-permissions-landscape.md`](./research-permissions-landscape.md) +for the fully-sourced survey. Summary of what was reused vs. referenced: + +- **OpenCode permission system** β€” directly ported (config shape, action enum, + ask/respond, `once`/`always`/`reject`, glob matcher). Primary reuse. +- **Claude Code `--permission-mode plan` / `canUseTool` `{behavior:"allow"|"deny"}`** β€” + informed the mode names and the allow/deny JSON response shape. +- **MCP elicitation `accept`/`decline`/`cancel`** β€” informed the three-action + response model (`once`/`always`/`reject`). +- **OpenAI Codex sandbox `read-only`/`workspace-write`** and **Gemini/Qwen + `--approval-mode plan`** β€” informed mode naming and the comparison matrix. +- **Vercel AI SDK 6 `needsApproval`** β€” noted as in-process JS-only prior art; + not adopted (not JSON-transport, not cross-language). + +## Verification + +- `js/tests/permission.test.ts` β€” 22 tests covering `modePolicy`, + `parseOverride`, `policy()` resolution/merge, `evaluateBash`, `bashEnforced`, + `check()` allow/deny, and the full askβ†’respond lifecycle (`once`/`reject`/`always`). +- `js/tests/read-only.test.ts` β€” unchanged, still green (hard tool-removal layer). +- `rust` β€” `cargo test permission` covers mode policy, override parsing/merge, + bash evaluation, and JSON event (de)serialization. +- Full JS suite: `bun test` green. + +## Files Changed + +- `js/src/permission/index.ts` (new) β€” permission namespace. +- `js/src/tool/{bash,edit,write,patch,webfetch}.ts` β€” enforcement calls. +- `js/src/cli/{event-handler,input-queue,continuous-mode,run-options}.js`, + `js/src/config/config.ts` β€” JSON I/O + CLI flags + config. +- `js/tests/permission.test.ts` (new). +- `rust/src/permission.rs` (new) + `rust/src/cli.rs` flags + `rust/src/main.rs` wiring. +- `docs/permissions.md` (new), `docs/case-studies/issue-271/*`, README/TOOLS updates. diff --git a/docs/case-studies/issue-271/research-data.json b/docs/case-studies/issue-271/research-data.json new file mode 100644 index 00000000..fd8fdb7f --- /dev/null +++ b/docs/case-studies/issue-271/research-data.json @@ -0,0 +1,91 @@ +{ + "issue": { + "number": 271, + "title": "Add a native read-only / planning permission mode (enforceable via agent-commander --read-only)", + "url": "https://github.com/link-assistant/agent/issues/271", + "createdAt": "2026-06-17T12:46:31Z", + "authoritativeComment": "https://github.com/link-assistant/agent/issues/271#issuecomment-4730457589" + }, + "pr": { + "number": 272, + "branch": "issue-271-3f282f6e5862" + }, + "checkedAt": "2026-06-17", + "sources": [ + "https://opencode.ai/docs/permissions", + "https://opencode.ai/docs/agents/", + "https://github.com/sst/opencode", + "https://docs.claude.com/en/docs/claude-code/sdk", + "https://docs.claude.com/en/docs/claude-code/iam", + "https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation", + "https://developers.openai.com/codex/local-config", + "https://google-gemini.github.io/gemini-cli/", + "https://qwenlm.github.io/qwen-code-docs/", + "https://vercel.com/blog/ai-sdk-6" + ], + "requirements": [ + { "id": "R1", "text": "Re-add OpenCode's permission system in the most similar way possible", "status": "done" }, + { "id": "R2", "text": "Default behavior remains full auto-mode (no behavior change)", "status": "done" }, + { "id": "R3", "text": "Plan, readonly, and approve-each-command via CLI options", "status": "done" }, + { "id": "R4", "text": "Fully controllable from JSON (input and output), no TUI", "status": "done" }, + { "id": "R5", "text": "Detailed docs on how to send approval JSON", "status": "done" }, + { "id": "R6", "text": "Fail clearly if the mode cannot be honored", "status": "done" }, + { "id": "R7", "text": "Expressible through OpenCode-compatible permission/deny rules", "status": "done" }, + { "id": "R8", "text": "Implement in both JavaScript and Rust", "status": "done" }, + { "id": "R9", "text": "Case study folder with deep analysis, research, requirements, solution plans", "status": "done" }, + { "id": "R10", "text": "No TUI integration", "status": "done" } + ], + "design": { + "modes": ["auto", "plan", "readonly", "ask"], + "actions": ["allow", "ask", "deny"], + "responses": ["once", "always", "reject"], + "policyShape": { "edit": "Action", "bash": "Record", "webfetch": "Action" }, + "defaultMode": "auto", + "envVars": { + "LINK_ASSISTANT_AGENT_PERMISSION_MODE": "auto", + "LINK_ASSISTANT_AGENT_PERMISSION": "" + }, + "cliFlags": ["--permission-mode", "--permission"], + "jsonRequest": { + "type": "permission_request", + "fields": ["timestamp", "sessionID", "permissionID", "callID", "tool", "pattern", "title", "metadata"] + }, + "jsonResponse": { + "type": "permission_response", + "fields": ["permissionID", "response"], + "responseEnum": ["once", "always", "reject"] + } + }, + "priorArt": [ + { + "name": "OpenCode", + "reuse": "ported", + "facts": "Three actions allow/ask/deny; per-tool bash/edit/webfetch; bash string or {pattern:action}; last-matching-rule-wins; HTTP POST /session/:id/permissions/:permissionID with {response: once|always|reject, remember?}; permission.updated SSE event." + }, + { + "name": "Claude Code", + "reuse": "referenced", + "facts": "--permission-mode plan; canUseTool / --permission-prompt-tool MCP returns {behavior: 'allow'|'deny', updatedInput?, message?}; permissions.{allow,ask,deny,defaultMode}; Bash(git log:*) rule syntax." + }, + { + "name": "MCP elicitation", + "reuse": "referenced", + "facts": "elicitation/create with requestedSchema; response action accept|decline|cancel (three-action model)." + }, + { + "name": "OpenAI Codex", + "reuse": "referenced", + "facts": "sandbox read-only|workspace-write|danger-full-access; approval untrusted|on-failure|on-request|never; --dangerously-bypass-approvals-and-sandbox (--yolo)." + }, + { + "name": "Gemini CLI / Qwen Code", + "reuse": "referenced", + "facts": "--approval-mode plan|default|auto_edit|yolo; tool allowlist in plan mode." + }, + { + "name": "Vercel AI SDK 6", + "reuse": "not-adopted", + "facts": "needsApproval per-tool flag; in-process JS-only, not a JSON transport, not cross-language." + } + ] +} diff --git a/docs/case-studies/issue-271/research-permissions-landscape.md b/docs/case-studies/issue-271/research-permissions-landscape.md new file mode 100644 index 00000000..eaed17ed --- /dev/null +++ b/docs/case-studies/issue-271/research-permissions-landscape.md @@ -0,0 +1,253 @@ +# Case Study Research: Native Enforceable Read-Only / Planning / Per-Command-Approval for `@link-assistant/agent` + +Research compiled 2026-06-17. Every claim is sourced. Quotes are verbatim; JSON/flag shapes are reproduced exactly. Where a fact is inferred rather than directly quotable, it is flagged. + +--- + +## 1. OpenCode (`sst/opencode`) permission system + +OpenCode β€” which `@link-assistant/agent` forked and then stripped β€” has a mature, JSON-first permission system. This is the most directly reusable prior art. + +### 1.1 The `permission` config schema + +Three action values (verbatim, https://opencode.ai/docs/permissions): +- `"allow"` β€” run without approval +- `"ask"` β€” prompt for approval +- `"deny"` β€” block the action + +**Simple form** (global `*` plus per-tool overrides): +```json +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "*": "ask", + "bash": "allow", + "edit": "deny" + } +} +``` + +**Single-value form:** `"permission": "allow"` + +**Granular per-pattern (object) form** for `bash`/`edit`: +```json +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "rm *": "deny", + "grep *": "allow" + }, + "edit": { + "*": "deny", + "packages/web/src/content/docs/*.mdx": "allow" + } + } +} +``` + +**Pattern evaluation rule** (verbatim): "Rules are evaluated by pattern match, with the **last matching rule winning**." Wildcards: `*` matches zero-or-more chars, `?` matches exactly one. + +**Available permission keys** (verbatim list): `read`, `edit` (covers `edit`/`write`/`patch`), `glob`, `grep`, `bash`, `task` (subagents), `skill`, `lsp`, `question`, `webfetch`, `websearch`, `external_directory`, `doom_loop`. + +Source schema (`packages/core/src/v1/config/permission.ts`, branch `dev`): +```ts +export const Action = Schema.Literals(["ask", "allow", "deny"]) +export const Object = Schema.Record(Schema.String, Action) +export const Rule = Schema.Union([Action, Object]) +``` +Note: `permission` superseded the deprecated legacy `tools` boolean config as of `v1.1.1`. + +Sources: https://opencode.ai/docs/permissions Β· https://github.com/sst/opencode/blob/dev/packages/core/src/v1/config/permission.ts + +### 1.2 `OPENCODE_PERMISSION` env var + +From the CLI env-var table (https://opencode.ai/docs/cli/): + +| Variable | Type | Description | +| --- | --- | --- | +| `OPENCODE_PERMISSION` | string | Inlined json permissions config | + +It takes the **same JSON shape** as the `permission` config object, supplied inline as a string. The docs likewise describe `OPENCODE_CONFIG_CONTENT` as "Inline json config content". There is also an experimental `OPENCODE_EXPERIMENTAL_PLAN_MODE` toggle and a `--dangerously-skip-permissions` flag on `opencode run` ("Auto-approve permissions that are not explicitly denied (dangerous!)"). + +> Caveat: the docs table states *what* the variable is but does **not** contain a verbatim sentence asserting precedence over the config file. Treat "env overrides config" as inferred, not directly quotable. + +Source: https://opencode.ai/docs/cli/ + +### 1.3 Plan vs Build agent modes + +Both are `primary` agents (https://opencode.ai/docs/agents/): +- **Build** (verbatim): "the **default** primary agent with all tools enabled… full access to file operations and system commands." +- **Plan** (verbatim): "A restricted agent designed for planning and analysis… By default, all of the following are set to `ask`: `file edits` (All writes, patches, and edits), `bash` (All bash commands)… analyze code, suggest changes, or create plans without making any actual modifications." + +> Important: the built-in Plan agent defaults edit/bash to **`ask`**, not hard `deny`. The docs' JSON example that uses `"edit":"deny","bash":"deny"` is an illustrative *override*, not the built-in default. Agent permissions are merged with global config, "and agent rules take precedence." + +Source: https://opencode.ai/docs/agents/ + +### 1.4 Permission prompt / response flow (server + SDK) + +**HTTP endpoint** (verbatim, https://opencode.ai/docs/server/): +| `POST` | `/session/:id/permissions/:permissionID` | Respond to a permission request | body: `{ response, remember? }`, returns `boolean` | + +**SSE bus events** (source `packages/opencode/src/permission/index.ts`): `permission.asked` and `permission.replied`, delivered on the `GET /event` SSE stream. + +**Three reply values** (source `packages/core/src/v1/permission.ts`): +```ts +export const Reply = Schema.Literals(["once", "always", "reject"]) +``` +- `once` β€” approve just this request +- `always` β€” approve future requests matching the suggested patterns (rest of session); patterns are tool-provided (e.g. bash whitelists a safe prefix like `git status*`) +- `reject` β€” deny (raises `RejectedError`, or `CorrectedError` if a `message` is supplied; also rejects other pending requests in the session) + +> **This is the key finding for `@link-assistant/agent`:** the fork's own merged PR #271 already wired the consumer side of this exact flow. `js/src/cli/cmd/run.ts` listens for `permission.updated` events and POSTs to `postSessionIdPermissionsPermissionId` with `once`/`always`/`reject`. The infrastructure survived the fork; only the policy layer was stripped. + +Sources: https://opencode.ai/docs/server/ Β· https://github.com/sst/opencode/blob/dev/packages/opencode/src/permission/index.ts Β· https://github.com/sst/opencode/blob/dev/packages/core/src/v1/permission.ts + +### 1.5 Default behavior (no config) + +Verbatim: "Most permissions default to `"allow"`. `doom_loop` and `external_directory` default to `"ask"`. `read` is `"allow"`, but `.env` files are denied by default" (`*.env` β†’ `deny`, `*.env.example` β†’ `allow`). + +Source: https://opencode.ai/docs/permissions + +--- + +## 2. `link-assistant/agent-commander` read-only model (issue #20 / PR #21) + +**Issue #20** ("Add hard read-only / no-shell tool mode for planning tasks", author konard, CLOSED) was driven by Hive Mind issue #501: split a GitHub issue into sub-issues by asking an agent *only for a JSON plan*, with the app performing all mutations deterministically. Requirement (verbatim): a `start-agent` flag such as `--read-only`/`--plan-only`/`--disable-tools shell,bash,write`, mapped to "the safest available native options," and **"If a selected tool cannot enforce the requested restrictions, `start-agent` should fail clearly instead of silently running with broader permissions."** + +**PR #21** ("Add read-only planning mode for agent tools", MERGED 2026-04-26) implemented `--read-only` and `--plan-only` in both JS and Rust. The per-tool native mapping (verbatim from the merged docs/case-study): + +| Tool | Read-only mapping | Meaning | +|------|---------|----------| +| Claude | `--permission-mode plan` | Analyze without file edits or command execution | +| Codex | `--ask-for-approval never exec --sandbox read-only` | Non-interactive read-only sandbox | +| OpenCode | `OPENCODE_PERMISSION='{"edit":"deny","bash":"deny","task":"deny"}'` | Blocks file edits, shell commands, subagent launches | +| Qwen | `--approval-mode plan` | Plan mode for read-only analysis | +| Gemini | `--approval-mode plan` | Plan mode for read-only exploration | + +The **read-only matrix** marked `agent` (i.e. `@link-assistant/agent`) as the one tool that is **"❌ not enforceable"** β€” verbatim: *"`--tool agent --read-only` is rejected because @link-assistant/agent has no native permission system."* Before PR #21, every tool ran with autonomous defaults (Claude `--dangerously-skip-permissions`, Codex `--dangerously-bypass-approvals-and-sandbox`, Qwen/Gemini yolo-style, OpenCode no override). PR #21 also confirmed screen-isolation wrapping is preserved for restricted commands. + +This is precisely the gap that issue #271 closes: it gives `agent` a native permission system so agent-commander's uniform `--read-only` becomes enforceable. (The fork's merged PR #271 implements `--read-only` + `--disable-tools` with enforcement at tool resolution and inside the batch tool, plus a read-only system-prompt note β€” see `js/src/tool/registry.ts`, `js/src/tool/batch.ts`, `js/src/cli/run-options.js:215`.) + +Sources: `gh issue view 20 --repo link-assistant/agent-commander` Β· `gh pr view 21 --repo link-assistant/agent-commander` (https://github.com/link-assistant/agent-commander/pull/21) Β· local commit `1bd2427`. + +--- + +## 3. How comparable CLIs implement read-only / plan / approval + +### 3.1 Claude Code + +**`--permission-mode`** accepts (current docs, 6 modes): `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`. "Overrides `defaultMode` from settings files." What runs without a prompt (verbatim): + +| Mode | Allowed without prompt | +|------|----------| +| `default` | Reads only (prompts on first use of each tool) | +| `acceptEdits` | Reads, file edits, common fs commands (`mkdir`, `touch`, `mv`, `cp`, `rm`, `rmdir`, `sed`) | +| `plan` | Reads only β€” "research and propose changes without making them" | +| `bypassPermissions` | Everything | + +`plan` mode is **hard-enforced read-only in the SDK** (verbatim): "`plan` routes file-edit and shell-write tools to your `canUseTool` callback regardless of allow rules, so write operations cannot be auto-approved while planning." The `--dontAsk` mode "Auto-denies tools unless pre-approved" β€” relevant for locked-down CI. + +**`--allowedTools` / `--disallowedTools`** (verbatim): +- `--allowedTools` β€” "Tools that execute without prompting." Example: `"Bash(git log *)" "Bash(git diff *)" "Read"`. The `:*` suffix is equivalent to space-wildcard: `Bash(ls:*)` ≑ `Bash(ls *)`. +- `--disallowedTools` β€” "A bare tool name removes the matching tools from the model's context… A scoped rule such as `Bash(rm *)` leaves the tool available and denies only matching calls." + +**Approvals in print / stream-json mode.** `--output-format` ∈ {`text`,`json`,`stream-json`}; `--input-format` ∈ {`text`,`stream-json`}. Two mechanisms: + +1. **`canUseTool` callback** (TS SDK) returns a `PermissionResult` union (verbatim): +```ts +type PermissionResult = + | { behavior: "allow"; updatedInput?: Record; updatedPermissions?: PermissionUpdate[]; toolUseID?: string } + | { behavior: "deny"; message: string; interrupt?: boolean; toolUseID?: string }; +``` +2. **`--permission-prompt-tool `** β€” "Specify an MCP tool to handle permission prompts in non-interactive mode." The MCP tool returns the same `behavior`/`updatedInput`/`message` decision, wrapped as stringified JSON in an MCP `content`/`type:"text"` block: +```jsonc +// allow: { "content": [ { "type":"text", "text":"{\"behavior\":\"allow\",\"updatedInput\":{...}}" } ] } +// deny: { "content": [ { "type":"text", "text":"{\"behavior\":\"deny\",\"message\":\"...\"}" } ] } +``` +> The inner field names (`behavior`/`updatedInput`/`message`) are primary-sourced from the `canUseTool` docs. The MCP `content` text-wrapper is only secondary-sourced in currently-live docs (legacy SDK pages now redirect) β€” treat as high-confidence but secondary. + +**`settings.json` permissions block** (verbatim): `permissions.allow`, `permissions.ask`, `permissions.deny` (arrays of `Tool` / `Tool(specifier)` rules), plus `permissions.defaultMode`. Evaluation order: **deny β†’ ask β†’ allow** (first match wins); SDK full order: Hooks β†’ Deny β†’ Ask β†’ Permission mode β†’ Allow β†’ `canUseTool`. + +**`--dangerously-skip-permissions`** β€” "Equivalent to `--permission-mode bypassPermissions`." Refuses to run as root/sudo; can be blocked via `permissions.disableBypassPermissionsMode: "disable"`. + +Sources: https://code.claude.com/docs/en/cli-reference Β· https://code.claude.com/docs/en/permission-modes Β· https://code.claude.com/docs/en/permissions Β· https://code.claude.com/docs/en/headless Β· https://code.claude.com/docs/en/agent-sdk/permissions Β· https://code.claude.com/docs/en/agent-sdk/user-input Β· https://code.claude.com/docs/en/agent-sdk/typescript + +### 3.2 Gemini CLI + +**`--approval-mode`** (string, default `default`): choices `default`, `auto_edit`, `yolo`, `plan` (note the canonical flag value is `auto_edit` with an underscore; prose docs also write `auto-edit`). `--yolo`/`-y` is **deprecated** in favor of `--approval-mode=yolo`. Shift+Tab cycles `Default β†’ Auto-Edit β†’ Plan`. + +**Plan Mode** (verbatim): "a read-only environment for architecting robust solutions before implementation… Explore the project in a read-only state to prevent accidental changes." Allowed tools are strictly an allowlist (`read_file`, `list_directory`, `glob`, `grep_search`, `google_web_search`, `web_fetch`, research subagents, `ask_user`, read-only MCP tools); `write_file`/`replace` allowed **only** for `.md` files in the plans dir; **no `run_shell_command`**. Enforced by a built-in Tier-1 policy engine (`plan.toml`). + +Sources: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/cli-reference.md Β· https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/plan-mode.md + +### 3.3 OpenAI Codex CLI + +Codex separates **sandbox** from **approval**: + +**`--sandbox`** values (verbatim): +- `read-only` β€” "Codex can inspect files, but it can't edit files or run commands without approval." +- `workspace-write` β€” "read files, edit within the workspace, and run routine local commands inside that boundary." +- `danger-full-access` β€” "runs without sandbox restrictions… removes the filesystem and network boundaries." + +**`--ask-for-approval` / `-a`** values: `untrusted` (deprecated), `on-failure` (deprecated), `on-request` ("Prompts before executing commands in interactive sessions"), `never` ("Runs commands without interruption in non-interactive contexts"). `--full-auto` is a deprecated legacy alias for `--sandbox workspace-write`. + +**config.toml**: `approval_policy` (`untrusted`/`on-request`/`never`, or granular object `{ granular = { sandbox_approval, rules, mcp_elicitations, request_permissions, skill_approval } }`) and `sandbox_mode` (`read-only`/`workspace-write`/`danger-full-access`): +```toml +approval_policy = "on-request" +sandbox_mode = "workspace-write" +``` +**`--dangerously-bypass-approvals-and-sandbox`** (alias `--yolo`) β€” "No sandbox; no approvals (not recommended)." + +Sources: https://developers.openai.com/codex/concepts/sandboxing Β· https://developers.openai.com/codex/cli/reference Β· https://developers.openai.com/codex/config-reference Β· https://developers.openai.com/codex/agent-approvals-security + +### 3.4 Qwen Code CLI + +**`--approval-mode` / `/approval-mode`** β€” five modes; config key `tools.approvalMode` in `.qwen/settings.json` (`"plan"`, `"default"`, `"auto-edit"`, `"auto"`, `"yolo"`): +- **Plan** β€” file editing "❌ Read-only analysis only", shell "❌ Not executed" (lowest risk) +- **Ask Permissions** (config `default`) β€” both require manual approval +- **Auto-Edit** β€” edits auto-approved (`edit`, `write_file`, `notebook_edit`), shell manual +- **Auto** β€” classifier-evaluated, fail-closed if classifier unreachable +- **YOLO** β€” everything auto-approved + +Plan mode (verbatim): "create a plan by analyzing the codebase with **read-only** operations." Cycle order: `plan β†’ default β†’ auto-edit β†’ auto β†’ yolo`. + +Sources: https://qwenlm.github.io/qwen-code-docs/en/users/features/approval-mode/ Β· https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/approval-mode.md + +--- + +## 4. Existing reusable libraries / components + +**MCP elicitation** (spec `2025-06-18`): a server-requests-structured-input-from-user primitive. Request `elicitation/create` with `message` + `requestedSchema`; response three-action model `action: "accept" | "decline" | "cancel"` with `content`. **But the spec explicitly says "Servers MUST NOT use elicitation to request sensitive information,"** and the shape carries no `updatedInput` or rule scope β€” so it is a form/consent primitive, not a purpose-built tool-call gate. Source: https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation + +**Claude `permissionPromptToolName`** (= CLI `--permission-prompt-tool`): nominate an MCP tool that returns the `PermissionResult` (`{behavior:"allow",updatedInput?}` / `{behavior:"deny",message}`). Source: https://code.claude.com/docs/en/agent-sdk/typescript + +**Vercel AI SDK** β€” the brief's premise ("no built-in canUseTool") is **outdated as of AI SDK 6**, which added `needsApproval` (boolean or `(input)=>boolean`) β†’ tool enters `approval-requested` state β†’ host calls `addToolApprovalResponse({id, approved})`. JS-only, in-process; **no Rust equivalent and not a JSON-over-stdin protocol.** Source: https://vercel.com/blog/ai-sdk-6 + +**Generic policy engines** (decision engines you embed behind your own gate, not approval *protocols*): Cedar (`cedar-policy` Rust crate + WASM/JS bindings, https://crates.io/crates/cedar-policy), OPA/Rego, Casbin. `agent-fetch` (npm + crate) is the closest cross-language "agent gating" precedent but is network-only. + +**Assessment.** For a JS+Rust OpenCode fork needing JSON-I/O-controllable read-only/plan/per-command approval: **reuse OpenCode's own stripped permission model as the core** β€” its three-state `allow`/`ask`/`deny` config with per-tool glob rules and "last matching rule wins" already expresses read-only/plan/per-command gating in plain JSON, the fork still vendors the reference implementation at `original-opencode/packages/opencode/src/permission/index.ts`, and the consumer-side reply flow (`once`/`always`/`reject`) is already wired in `js/src/cli/cmd/run.ts`. Adopt the Anthropic `PermissionResult` shape (`behavior`/`updatedInput`/`message`) as the JSON-over-stdin decision contract (de-facto standard, trivially a Rust enum, gives input-rewriting for free). Use MCP elicitation / `permissionPromptToolName` only as an optional interactive transport, not the primary primitive. Reach for Cedar (Rust + WASM) only if declarative org-wide policy is later needed. Do **not** reimplement from scratch. + +--- + +## 5. Comparison table β€” read-only / plan / approval flags + +| Capability | Claude Code | OpenAI Codex | Gemini CLI | Qwen Code | OpenCode | `@link-assistant/agent` (after #271) | +|---|---|---|---|---|---|---| +| **Read-only / plan flag** | `--permission-mode plan` | `--sandbox read-only` | `--approval-mode plan` | `--approval-mode plan` | Plan agent / `OPENCODE_PERMISSION` deny | `--read-only` / `--disable-tools` | +| **Approval policy values** | `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions` | sandbox: `read-only`/`workspace-write`/`danger-full-access`; approval: `untrusted`/`on-failure`/`on-request`/`never` | `default`, `auto_edit`, `yolo`, `plan` | `plan`, `default`, `auto-edit`, `auto`, `yolo` | per-tool `allow`/`ask`/`deny` | env: `LINK_ASSISTANT_AGENT_READ_ONLY`, `LINK_ASSISTANT_AGENT_DISABLE_TOOLS` | +| **Allow/deny specific tools** | `--allowedTools` / `--disallowedTools` (`Bash(git log:*)`) | sandbox + approval policy | tool allowlist (plan mode) | mode-based | `permission: {bash:{...},edit:{...}}` glob rules | `--disable-tools bash,edit,write,...` | +| **Bypass / YOLO** | `--dangerously-skip-permissions` | `--dangerously-bypass-approvals-and-sandbox` (`--yolo`) | `--approval-mode yolo` (`-y` deprecated) | `--approval-mode yolo` | `--dangerously-skip-permissions` | (n/a) | +| **Config-file equivalent** | `permissions.{allow,ask,deny,defaultMode}` | `approval_policy`, `sandbox_mode` (config.toml) | `--approval-mode` / settings | `tools.approvalMode` (.qwen/settings.json) | `permission` block in opencode.json | config + flags | +| **Programmatic approval over JSON** | `canUseTool` / `--permission-prompt-tool` MCP β†’ `{behavior:"allow"|"deny",...}` | MCP elicitations (granular approval) | policy engine (plan.toml) | classifier (auto mode) | `POST /session/:id/permissions/:permissionID` `{response,remember?}`; replies `once`/`always`/`reject` | `permission.updated` SSE + POST reply (inherited from OpenCode) | + +--- + +### Verification caveats to carry into the case study +1. **OpenCode Plan default = `ask`, not `deny`** β€” the `deny` JSON in the docs is an override example. agent-commander's mapping deliberately uses hard `deny` via `OPENCODE_PERMISSION`. +2. **`OPENCODE_PERMISSION` precedence over config file** is inferred, not verbatim-documented. +3. **Claude `--permission-prompt-tool` MCP `content`/text wrapper** is secondary-sourced (live first-party SDK pages now redirect); the inner `behavior`/`updatedInput`/`message` fields are primary-sourced. +4. **Vercel "no canUseTool"** premise is outdated β€” AI SDK 6 ships `needsApproval` (JS-only, in-process). +5. Codex `untrusted`/`on-failure` and Gemini/Codex `--full-auto`/`-y` are **deprecated**; prefer `on-request`/`never` and `--approval-mode=*`. \ No newline at end of file diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 00000000..be7715c1 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,182 @@ +# Permission System (read-only / plan / per-command approval) + +The Agent CLI has a native, **enforceable** permission system, ported from +OpenCode but driven entirely over **JSON** β€” there is **no TUI**. It lets you run +the agent in read-only or planning modes, or approve each mutating command +individually, while keeping **full auto-mode the default** so nothing changes for +existing consumers. + +This is the native counterpart of `agent-commander`'s uniform `--read-only` +flag (issue [#271](https://github.com/link-assistant/agent/issues/271)). + +## TL;DR + +```bash +# Default β€” full auto, never asks (unchanged behavior): +agent -p "refactor this file" + +# Hard read-only: deny every edit and any non read-only shell command, never asks: +agent --permission-mode readonly -p "summarize the repo layout" + +# Planning: deny edits, allow read-only shell, ask before anything else: +agent --permission-mode plan --input-format stream-json + +# Ask before every mutating tool (per-command approval over JSON): +agent --permission-mode ask --input-format stream-json + +# OpenCode-compatible fine-grained override (merged on top of the mode): +agent --permission '{"edit":"ask","bash":{"git push*":"ask","*":"allow"}}' +``` + +## Modes + +`--permission-mode ` (env `LINK_ASSISTANT_AGENT_PERMISSION_MODE`): + +| Mode | `edit` / `write` / `patch` | `bash` | `webfetch` | Asks? | +|------|----------------------------|--------|------------|-------| +| `auto` *(default)* | allow | allow | allow | never | +| `plan` | **deny** | read-only commands **allow**, else **ask** | allow | yes | +| `readonly` | **deny** | read-only commands **allow**, else **deny** | allow | never | +| `ask` | ask | ask (every command) | ask | yes | + +The read-only shell allowlist includes `cat`, `ls`, `pwd`, `grep`/`rg`, `head`, +`tail`, `wc`, `stat`, `file`, `find` (read-only forms), `diff`, `tree`, +`git diff`/`log`/`status`/`show`/`branch`, and similar. Destructive variants +(`find … -delete`/`-exec`, `sort -o`, output redirection `>`, command +substitution `$(…)`/backticks) fall back to the mode's non-allow action. + +> **Hard layer.** `--read-only` and `--disable-tools bash,edit,write,multiedit,patch` +> remove tools from the model entirely (they are never even offered). The +> permission system is the finer-grained layer; combine both for defense in +> depth. + +## Fine-grained override: `--permission` + +`--permission ''` (env `LINK_ASSISTANT_AGENT_PERMISSION`) takes an +OpenCode-compatible policy and is **merged on top of the mode** (override wins; +`bash` maps merge key-by-key): + +```jsonc +{ + "edit": "ask", // "allow" | "ask" | "deny" + "webfetch": "allow", + "bash": { // string ("ask") or a {glob: action} map + "git push*": "ask", + "rm*": "deny", + "*": "allow" // catch-all + } +} +``` + +Bash globs are matched with the same structured wildcard matcher as OpenCode +(`*` matches any token sequence; **the longest / last matching rule wins**). Each +command in a chain (`a && b`, `a | b`, `$(c)`) is evaluated independently. + +## JSON protocol + +### 1. Request (agent β†’ you) + +When a tool needs approval, the agent emits a `permission_request` event on +stdout: + +```json +{ + "type": "permission_request", + "timestamp": 1718630400000, + "sessionID": "ses_abc123", + "permissionID": "per_xyz789", + "callID": "call_001", + "tool": "bash", + "pattern": ["npm install *"], + "title": "npm install", + "metadata": { "command": "npm install", "patterns": ["npm install *"] } +} +``` + +| Field | Meaning | +|-------|---------| +| `permissionID` | Opaque id you must echo back to approve/deny this request. | +| `sessionID` | Session the request belongs to. | +| `callID` | The tool call id (if any). | +| `tool` | `bash`, `edit`, or `webfetch`. | +| `pattern` | For `bash`, the glob pattern(s) being approved; absent for others. | +| `title` | Human-readable summary (the command, file path, or URL). | +| `metadata` | Tool-specific details. | + +### 2. Response (you β†’ agent) + +Send a `permission_response` frame on stdin: + +```json +{ "type": "permission_response", "permissionID": "per_xyz789", "response": "once" } +``` + +`response` is one of: + +| Value | Effect | +|-------|--------| +| `once` | Allow this single request and continue. | +| `always` | Allow this request **and** auto-approve later matching requests in the same session. | +| `reject` | Deny. The tool call fails with a `RejectedError`; the model may retry differently. | + +The same frame works in both input formats: + +- **Text mode** (default): one JSON object per line. +- **`--input-format stream-json`** (Claude-compatible NDJSON): one frame per line. + +`permissionID` may also be sent as `permission_id`. + +## Input mode requirements + +- `auto` and `readonly` **never ask**, so they work with any input mode, + including single-shot `--prompt`. +- `plan` and `ask` **emit requests mid-turn and block** until you respond. They + require a streaming input mode so you can reply while the turn is in flight: + + ```bash + agent --permission-mode ask --input-format stream-json + ``` + + Single-shot `--prompt` with an ask-mode would deadlock on the first request. + +## End-to-end example (`ask` mode) + +```bash +agent --permission-mode ask --input-format stream-json <<'EOF' +{"type":"user","message":"create hello.txt with the text hi"} +EOF +``` + +The agent streams a request: + +```json +{"type":"permission_request","permissionID":"per_001","tool":"edit","title":"/work/hello.txt", ...} +``` + +Reply on stdin to approve: + +```json +{"type":"permission_response","permissionID":"per_001","response":"once"} +``` + +…or deny: + +```json +{"type":"permission_response","permissionID":"per_001","response":"reject"} +``` + +## Environment variables + +| Variable | Equivalent flag | Default | +|----------|-----------------|---------| +| `LINK_ASSISTANT_AGENT_PERMISSION_MODE` | `--permission-mode` | `auto` | +| `LINK_ASSISTANT_AGENT_PERMISSION` | `--permission` | *(none)* | + +## Notes + +- **No TUI.** Approvals are pure JSON; there is no interactive terminal prompt. +- **Full auto by default.** In `auto` mode the bash policy is `{"*":"allow"}`, so + the tree-sitter command parse is skipped and there is zero added overhead. +- See the case study under + [`docs/case-studies/issue-271`](case-studies/issue-271/README.md) for the + design rationale and the prior-art survey. diff --git a/js/.changeset/read-only-mode.md b/js/.changeset/read-only-mode.md new file mode 100644 index 00000000..c0670de9 --- /dev/null +++ b/js/.changeset/read-only-mode.md @@ -0,0 +1,16 @@ +--- +'@link-assistant/agent': minor +--- + +Add a native, enforceable permission system, ported from OpenCode and fully controllable over JSON with no TUI (issue #271). + +**Hard layer.** `--read-only` (env `LINK_ASSISTANT_AGENT_READ_ONLY`) disables every filesystem-mutating and shell tool β€” `bash`, `edit`, `write`, `multiedit`, `patch` β€” so the agent can only read, search and plan. The restriction is enforced at tool resolution and also when tools are invoked indirectly via the `batch` tool, so it cannot be bypassed. `--disable-tools bash,write,edit` (env `LINK_ASSISTANT_AGENT_DISABLE_TOOLS`) disables an explicit set of tools. This makes agent-commander's uniform `--read-only` flag enforceable for the `agent` tool, on par with the other supported tools. + +**Fine-grained layer.** `--permission-mode ` (env `LINK_ASSISTANT_AGENT_PERMISSION_MODE`) plus an OpenCode-compatible `--permission ''` override (env `LINK_ASSISTANT_AGENT_PERMISSION`). The default mode is `auto` (full autonomy, never asks), so existing behavior is unchanged and the full-auto path has zero added overhead. + +- `plan` denies edits, allows read-only shell commands, and asks before anything else. +- `readonly` denies all mutations (never asks). +- `ask` requests approval before every mutating tool. +- `--permission` accepts the OpenCode `{edit, bash, webfetch}` shape (where `bash` is a string or a `{glob: action}` map) and is merged on top of the mode. + +Approvals are exchanged purely as JSON: the agent emits a `permission_request` event on stdout and the consumer replies with a `permission_response` frame (`once` / `always` / `reject`) on stdin, in both text and `stream-json` input modes. See `docs/permissions.md` for the full protocol and `docs/case-studies/issue-271` for the design rationale and prior-art survey. The same policy/mode/override semantics, CLI flags, and JSON schemas are mirrored in the Rust implementation. diff --git a/js/README.md b/js/README.md index 32664f82..e7dbaa49 100644 --- a/js/README.md +++ b/js/README.md @@ -293,6 +293,18 @@ Options: --append-system-message Append to the default system message --append-system-message-file Append to the default system message from file +Permission Options: + --read-only Enforceable read-only / planning mode. + Disables all filesystem-mutating and shell + tools (bash, edit, write, multiedit, patch) + so the agent can only read, search and plan. + Env: LINK_ASSISTANT_AGENT_READ_ONLY=true + --disable-tools Comma-separated list of tool ids to disable + (e.g. "bash,write,edit"). Disabled tools are + never exposed to the model and are rejected if + invoked via batch. + Env: LINK_ASSISTANT_AGENT_DISABLE_TOOLS + Stdin Mode Options: -p, --prompt Direct prompt (bypasses stdin reading) --disable-stdin Disable stdin streaming (requires --prompt) diff --git a/js/src/cli/continuous-mode.js b/js/src/cli/continuous-mode.js index 2e0871ab..72c44526 100644 --- a/js/src/cli/continuous-mode.js +++ b/js/src/cli/continuous-mode.js @@ -10,6 +10,7 @@ import { Session } from '../session/index.ts'; import { SessionPrompt } from '../session/prompt.ts'; import { createEventHandler, serializeOutput } from '../json-standard/index.ts'; import { createContinuousStdinReader } from './input-queue.js'; +import { Permission } from '../permission/index.ts'; import { Log } from '../util/log.ts'; import { config } from '../config/config.ts'; import { createVerboseFetch } from '../util/verbose-fetch.ts'; @@ -323,6 +324,19 @@ export async function runContinuousServerMode( return; } + // Permission approval reply (issue #271). Resolve a pending tool + // permission request without interrupting the in-flight turn β€” this must + // bypass the `isProcessing` queue, since the turn is blocked waiting for it. + if (message.kind === 'permission_response') { + Permission.respond({ + sessionID, + permissionID: message.permissionID, + response: message.response, + }); + outputConsumedInput({ message, jsonStandard, sessionID, compactJson }); + return; + } + if (isProcessing) { pendingMessages.push(message); return; @@ -577,6 +591,19 @@ export async function runContinuousDirectMode( return; } + // Permission approval reply (issue #271). Resolve a pending tool + // permission request without interrupting the in-flight turn β€” this must + // bypass the `isProcessing` queue, since the turn is blocked waiting for it. + if (message.kind === 'permission_response') { + Permission.respond({ + sessionID, + permissionID: message.permissionID, + response: message.response, + }); + outputConsumedInput({ message, jsonStandard, sessionID, compactJson }); + return; + } + if (isProcessing) { pendingMessages.push(message); return; diff --git a/js/src/cli/event-handler.js b/js/src/cli/event-handler.js index f79372a1..04e56eff 100644 --- a/js/src/cli/event-handler.js +++ b/js/src/cli/event-handler.js @@ -81,6 +81,27 @@ export function createBusEventSubscription({ } } + // Emit a JSON permission request when a tool needs approval (issue #271). + // The consumer replies with a `permission_response` frame over stdin which + // is routed to Permission.respond. No TUI is involved. + if (event.type === 'permission.updated') { + const permission = event.properties; + if (permission.sessionID !== sessionID) { + return; + } + eventHandler.output({ + type: 'permission_request', + timestamp: Date.now(), + sessionID, + permissionID: permission.id, + callID: permission.callID, + tool: permission.type, + pattern: permission.pattern, + title: permission.title, + metadata: permission.metadata, + }); + } + // Handle session idle to know when to stop if ( event.type === 'session.idle' && diff --git a/js/src/cli/input-queue.js b/js/src/cli/input-queue.js index caa41bc1..82af9852 100644 --- a/js/src/cli/input-queue.js +++ b/js/src/cli/input-queue.js @@ -30,18 +30,47 @@ export class InputQueue { return parseStreamJsonInput(trimmed); } + let parsed; try { - const parsed = JSON.parse(trimmed); - // If it has a message field, use it directly - if (typeof parsed === 'object' && parsed !== null) { - return parsed; - } - // Otherwise wrap it - return { message: JSON.stringify(parsed) }; + parsed = JSON.parse(trimmed); } catch (_e) { // Not JSON, treat as plain text message return { message: trimmed }; } + + // If it has a message field, use it directly + if (typeof parsed === 'object' && parsed !== null) { + // Permission approval reply (issue #271) β€” normalize to a `kind` frame + // so it is routed to Permission.respond, mirroring stream-json mode. + if (parsed.type === 'permission_response') { + const permissionID = parsed.permissionID ?? parsed.permission_id; + const response = parsed.response; + if (typeof permissionID !== 'string' || !permissionID) { + throw new Error( + 'Invalid permission_response: expected string "permissionID"' + ); + } + if ( + response !== 'once' && + response !== 'always' && + response !== 'reject' + ) { + throw new Error( + 'Invalid permission_response: "response" must be "once", "always" or "reject"' + ); + } + return { + kind: 'permission_response', + permissionID, + response, + raw: trimmed, + parsed, + }; + } + return parsed; + } + // Otherwise wrap it + return { message: JSON.stringify(parsed) }; } /** @@ -283,6 +312,33 @@ export function parseStreamJsonInput(input) { }; } + // Permission approval reply (issue #271). Resolves a pending + // `permission_request` emitted by the agent. `response` is one of + // `once` | `always` | `reject`. + if (type === 'permission_response') { + const permissionID = frame.permissionID ?? frame.permission_id; + const response = frame.response; + if (typeof permissionID !== 'string' || !permissionID) { + throw new Error( + 'Invalid permission_response frame: expected string "permissionID"' + ); + } + if (response !== 'once' && response !== 'always' && response !== 'reject') { + throw new Error( + 'Invalid permission_response frame: "response" must be "once", "always" or "reject"' + ); + } + return { + kind: 'permission_response', + permissionID, + response, + raw: input, + parsed: frame, + format: 'stream-json', + inputType: type, + }; + } + if (type === 'user' || type === 'user_prompt' || type === undefined) { const message = extractFrameText(frame); if (message === null) { diff --git a/js/src/cli/run-options.js b/js/src/cli/run-options.js index 97ac414a..8b52cfd6 100644 --- a/js/src/cli/run-options.js +++ b/js/src/cli/run-options.js @@ -211,5 +211,28 @@ export function buildRunOptions(yargs, defaultOptions = {}) { type: 'number', description: 'Override the temperature for model completions. When not set, the default per-model temperature is used.', + }) + .option('read-only', { + type: 'boolean', + description: + 'Enforceable read-only / planning mode. Disables all filesystem-mutating and shell tools (bash, edit, write, multiedit, patch) so the agent can only read, search and plan. Maps to agent-commander --read-only.', + default: false, + }) + .option('disable-tools', { + type: 'string', + description: + 'Comma-separated list of tool ids to disable (e.g. "bash,write,edit"). Disabled tools are never exposed to the model and are rejected if invoked via batch.', + }) + .option('permission-mode', { + type: 'string', + choices: ['auto', 'plan', 'readonly', 'ask'], + description: + 'Permission policy for mutating tools. "auto" (default) allows everything; "plan" allows read-only shell and asks before mutations; "readonly" denies all mutations; "ask" asks before every mutating tool. Approvals are driven over JSON (permission_request / permission_response).', + default: 'auto', + }) + .option('permission', { + type: 'string', + description: + 'OpenCode-compatible permission override as JSON, merged on top of --permission-mode. Example: \'{"edit":"ask","bash":{"git push*":"ask","*":"allow"},"webfetch":"allow"}\'.', }); } diff --git a/js/src/config/config.ts b/js/src/config/config.ts index b779f19c..4a1d5d11 100644 --- a/js/src/config/config.ts +++ b/js/src/config/config.ts @@ -52,6 +52,10 @@ export interface AgentConfig { mcpDefaultToolCallTimeout: number; mcpMaxToolCallTimeout: number; verifyImagesAtReadTool: boolean; + readOnly: boolean; + disableTools: string; + permissionMode: string; + permission: string; } // Fallback helpers for when config is not yet initialized (early imports/tests) @@ -132,6 +136,10 @@ function defaultConfig(): AgentConfig { })(), verifyImagesAtReadTool: getEnvStr('LINK_ASSISTANT_AGENT_VERIFY_IMAGES_AT_READ_TOOL') !== 'false', + readOnly: truthyEnv('LINK_ASSISTANT_AGENT_READ_ONLY'), + disableTools: getEnvStr('LINK_ASSISTANT_AGENT_DISABLE_TOOLS') ?? '', + permissionMode: getEnvStr('LINK_ASSISTANT_AGENT_PERMISSION_MODE') ?? 'auto', + permission: getEnvStr('LINK_ASSISTANT_AGENT_PERMISSION') ?? '', }; } @@ -240,6 +248,31 @@ function buildAgentConfigOptions({ type: 'boolean', description: 'Verify images when using the read tool', default: getenv('LINK_ASSISTANT_AGENT_VERIFY_IMAGES_AT_READ_TOOL', true), + }) + .option('read-only', { + type: 'boolean', + description: + 'Enforceable read-only / planning mode. Disables all filesystem-mutating and shell tools (bash, edit, write, multiedit, patch).', + default: getenv('LINK_ASSISTANT_AGENT_READ_ONLY', false), + }) + .option('disable-tools', { + type: 'string', + description: + 'Comma-separated list of tool ids to disable (e.g. "bash,write,edit"). Disabled tools are never exposed to the model and are rejected if invoked via batch.', + default: getenv('LINK_ASSISTANT_AGENT_DISABLE_TOOLS', ''), + }) + .option('permission-mode', { + type: 'string', + choices: ['auto', 'plan', 'readonly', 'ask'], + description: + 'Permission policy for mutating tools. "auto" (default) allows everything; "plan" allows read-only shell and asks before mutations; "readonly" denies all mutations; "ask" asks before every mutating tool. Approvals are driven over JSON (permission_request / permission_response).', + default: getenv('LINK_ASSISTANT_AGENT_PERMISSION_MODE', 'auto'), + }) + .option('permission', { + type: 'string', + description: + 'OpenCode-compatible permission override as JSON, merged on top of --permission-mode. Example: \'{"edit":"ask","bash":{"git push*":"ask","*":"allow"},"webfetch":"allow"}\'.', + default: getenv('LINK_ASSISTANT_AGENT_PERMISSION', ''), }); } @@ -292,6 +325,10 @@ export function initConfig(argv?: string[]): AgentConfig { mcpDefaultToolCallTimeout: parsed.mcpDefaultToolCallTimeout ?? 120000, mcpMaxToolCallTimeout: parsed.mcpMaxToolCallTimeout ?? 600000, verifyImagesAtReadTool: parsed.verifyImagesAtReadTool ?? true, + readOnly: parsed.readOnly ?? false, + disableTools: parsed.disableTools ?? '', + permissionMode: parsed.permissionMode ?? 'auto', + permission: parsed.permission ?? '', }); // Propagate verbose to env var for subprocess resilience (issue #227). diff --git a/js/src/permission/index.ts b/js/src/permission/index.ts new file mode 100644 index 00000000..6d10f3ed --- /dev/null +++ b/js/src/permission/index.ts @@ -0,0 +1,564 @@ +/** + * Permission system (issue #271). + * + * Re-adds an OpenCode-style permission system to @link-assistant/agent that is + * fully controllable from JSON (input and output), with no TUI. + * + * By default the agent runs in **full auto mode** (every action is `allow`), so + * nothing changes for existing consumers. Opt-in CLI options switch the policy: + * + * --permission-mode auto (default) allow everything, never ask + * --permission-mode plan read-only planning: deny edits, allow read-only + * shell commands, ask before anything else + * --permission-mode readonly hard read-only: deny edits and any non + * read-only shell command (never asks) + * --permission-mode ask ask before every mutating tool (per-command + * approval driven over JSON) + * --permission '' explicit OpenCode-compatible override merged on + * top of the mode, e.g. + * '{"bash":{"git push*":"ask","*":"allow"},"edit":"ask"}' + * + * When a tool needs approval it publishes a `permission.updated` event (emitted + * to stdout as a `permission_request` JSON event) and waits. The consumer sends + * back a `permission_response` JSON frame which resolves `Permission.respond`. + * + * This is the native, enforceable counterpart of agent-commander's `--read-only` + * flag and of its (future) per-command approval UX. + */ +import z from 'zod'; +import { Bus } from '../bus'; +import { Log } from '../util/log'; +import { Identifier } from '../id/id'; +import { Instance } from '../project/instance'; +import { Wildcard } from '../util/wildcard'; +import { config } from '../config/config'; + +export namespace Permission { + const log = Log.create({ service: 'permission' }); + + // ─── Policy types ───────────────────────────────────────────────────────── + + export const Action = z.enum(['ask', 'allow', 'deny']); + export type Action = z.infer; + + export const Mode = z.enum(['auto', 'plan', 'readonly', 'ask']); + export type Mode = z.infer; + + /** Resolved permission policy. `bash` is a pattern map (OpenCode-compatible). */ + export interface Policy { + edit: Action; + bash: Record; + webfetch: Action; + } + + /** + * Read-only shell allowlist. Commands matching one of these patterns are safe + * to run in plan/readonly mode; everything else falls back to `fallback` + * (`ask` for plan, `deny` for readonly). + */ + function readonlyBash(fallback: Action): Record { + return { + 'cat*': 'allow', + 'cd*': 'allow', + 'cut*': 'allow', + 'date*': 'allow', + 'df*': 'allow', + 'diff*': 'allow', + 'dirname*': 'allow', + 'du*': 'allow', + 'echo*': 'allow', + env: 'allow', + 'file *': 'allow', + 'find *': 'allow', + 'git diff*': 'allow', + 'git log*': 'allow', + 'git show*': 'allow', + 'git status*': 'allow', + 'git branch': 'allow', + 'git branch -v': 'allow', + 'grep*': 'allow', + 'head*': 'allow', + 'hostname*': 'allow', + 'less*': 'allow', + 'ls*': 'allow', + 'more*': 'allow', + 'printenv*': 'allow', + 'pwd*': 'allow', + 'readlink*': 'allow', + 'realpath*': 'allow', + 'rg*': 'allow', + 'sort*': 'allow', + 'stat*': 'allow', + 'tail*': 'allow', + 'tree*': 'allow', + 'uname*': 'allow', + 'uniq*': 'allow', + 'wc*': 'allow', + 'whereis*': 'allow', + 'which*': 'allow', + 'whoami*': 'allow', + // Read-only command families that have destructive variants: + 'find * -delete*': fallback, + 'find * -exec*': fallback, + 'find * -execdir*': fallback, + 'find * -fprint*': fallback, + 'find * -fls*': fallback, + 'find * -fprintf*': fallback, + 'find * -ok*': fallback, + 'sort -o*': fallback, + 'sort --output*': fallback, + 'tree -o*': fallback, + // Catch-all: + '*': fallback, + }; + } + + /** Base policy for a mode, before any explicit `--permission` override. */ + export function modePolicy(mode: Mode): Policy { + switch (mode) { + case 'plan': + return { edit: 'deny', bash: readonlyBash('ask'), webfetch: 'allow' }; + case 'readonly': + return { edit: 'deny', bash: readonlyBash('deny'), webfetch: 'allow' }; + case 'ask': + return { edit: 'ask', bash: { '*': 'ask' }, webfetch: 'ask' }; + case 'auto': + default: + return { edit: 'allow', bash: { '*': 'allow' }, webfetch: 'allow' }; + } + } + + /** Parse the raw `--permission` JSON string into a partial policy. */ + export function parseOverride(raw: string | undefined): Partial { + if (!raw || !raw.trim()) return {}; + let parsed: any; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `Invalid --permission JSON: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Invalid --permission JSON: expected an object'); + } + const out: Partial = {}; + if (parsed.edit !== undefined) out.edit = Action.parse(parsed.edit); + if (parsed.webfetch !== undefined) + out.webfetch = Action.parse(parsed.webfetch); + if (parsed.bash !== undefined) { + if (typeof parsed.bash === 'string') { + out.bash = { '*': Action.parse(parsed.bash) }; + } else if ( + typeof parsed.bash === 'object' && + !Array.isArray(parsed.bash) + ) { + const bash: Record = {}; + for (const [pattern, value] of Object.entries(parsed.bash)) { + bash[pattern] = Action.parse(value); + } + out.bash = bash; + } else { + throw new Error( + 'Invalid --permission JSON: "bash" must be a string or object' + ); + } + } + return out; + } + + /** + * Resolve the active policy from the global config. Mode defines the base + * policy and `--permission` JSON is merged on top (override wins; bash maps + * are merged key-by-key). + */ + export function policy(): Policy { + const mode = Mode.parse(config.permissionMode ?? 'auto'); + const base = modePolicy(mode); + const override = parseOverride(config.permission); + return { + edit: override.edit ?? base.edit, + webfetch: override.webfetch ?? base.webfetch, + bash: { ...base.bash, ...(override.bash ?? {}) }, + }; + } + + const RESTRICTIVENESS: Record = { + allow: 0, + ask: 1, + deny: 2, + }; + + function moreRestrictive(a: Action, b: Action): Action { + return RESTRICTIVENESS[b] > RESTRICTIVENESS[a] ? b : a; + } + + function tokenize(segment: string): string[] { + return segment.trim().split(/\s+/).filter(Boolean); + } + + /** + * Evaluate a shell command against a bash pattern map. + * + * Faithful-enough port of OpenCode's structured matching, hardened for + * non-interactive enforcement: a command is only `allow`ed when *every* + * segment (split on `;`, `&&`, `||`, `|`) matches an allow pattern and the + * command contains no command substitution or output redirection. Otherwise + * it falls back to the catch-all action (`*`). The strongest enforcement + * remains tool removal via `--read-only` / `--disable-tools`. + */ + export function evaluateBash( + command: string, + patterns: Record + ): { action: Action; askPatterns: string[] } { + const fallback = patterns['*'] ?? 'allow'; + const askPatterns = new Set(); + + // Command substitution or output redirection can hide arbitrary writes. + const hasSubstitution = /\$\(|`/.test(command); + const hasRedirection = />>?/.test(command); + if (hasSubstitution || hasRedirection) { + if (fallback === 'ask') askPatterns.add(command); + return { action: fallback, askPatterns: Array.from(askPatterns) }; + } + + const segments = command + .split(/&&|\|\||;|\|/g) + .map((s) => s.trim()) + .filter(Boolean); + if (segments.length === 0) { + return { action: fallback, askPatterns: [] }; + } + + let action: Action = 'allow'; + for (const segment of segments) { + const tokens = tokenize(segment); + if (tokens.length === 0) continue; + const matched = Wildcard.allStructured( + { head: tokens[0], tail: tokens.slice(1) }, + patterns + ) as Action | undefined; + const segAction = matched ?? fallback; + action = moreRestrictive(action, segAction); + if (segAction === 'ask') { + const sub = tokens.slice(1).find((arg) => !arg.startsWith('-')); + askPatterns.add(sub ? `${tokens[0]} ${sub} *` : `${tokens[0]} *`); + } + } + return { action, askPatterns: Array.from(askPatterns) }; + } + + // ─── Ask / respond machinery (ported from OpenCode) ─────────────────────── + + function toKeys(pattern: Info['pattern'], type: string): string[] { + return pattern === undefined + ? [type] + : Array.isArray(pattern) + ? pattern + : [pattern]; + } + + function covered(keys: string[], approved: Record): boolean { + const pats = Object.keys(approved); + return keys.every((k) => pats.some((p) => Wildcard.match(k, p))); + } + + export const Info = z + .object({ + id: z.string(), + type: z.string(), + pattern: z.union([z.string(), z.array(z.string())]).optional(), + sessionID: z.string(), + messageID: z.string(), + callID: z.string().optional(), + title: z.string(), + metadata: z.record(z.string(), z.any()), + time: z.object({ + created: z.number(), + }), + }) + .meta({ + ref: 'Permission', + }); + export type Info = z.infer; + + export const Event = { + Updated: Bus.event('permission.updated', Info), + Replied: Bus.event( + 'permission.replied', + z.object({ + sessionID: z.string(), + permissionID: z.string(), + response: z.string(), + }) + ), + }; + + const state = Instance.state( + () => { + const pending: { + [sessionID: string]: { + [permissionID: string]: { + info: Info; + resolve: () => void; + reject: (e: any) => void; + }; + }; + } = {}; + + const approved: { + [sessionID: string]: { + [permissionID: string]: boolean; + }; + } = {}; + + return { + pending, + approved, + }; + }, + async (state) => { + for (const pending of Object.values(state.pending)) { + for (const item of Object.values(pending)) { + item.reject( + new RejectedError( + item.info.sessionID, + item.info.id, + item.info.callID, + item.info.metadata + ) + ); + } + } + } + ); + + export function pending() { + return state().pending; + } + + export const Response = z.enum(['once', 'always', 'reject']); + export type Response = z.infer; + + /** + * Ask for permission. Resolves immediately when already approved; rejects + * (throws via the caller) when denied. Otherwise registers a pending request, + * publishes `permission.updated`, and returns a promise resolved by + * `respond()`. + */ + export async function ask(input: { + type: Info['type']; + title: Info['title']; + pattern?: Info['pattern']; + callID?: Info['callID']; + sessionID: Info['sessionID']; + messageID: Info['messageID']; + metadata: Info['metadata']; + }) { + const { pending, approved } = state(); + log.info(() => ({ + message: 'asking', + sessionID: input.sessionID, + pattern: input.pattern, + })); + const approvedForSession = approved[input.sessionID] || {}; + const keys = toKeys(input.pattern, input.type); + if (covered(keys, approvedForSession)) return; + const info: Info = { + id: Identifier.ascending('permission'), + type: input.type, + pattern: input.pattern, + sessionID: input.sessionID, + messageID: input.messageID, + callID: input.callID, + title: input.title, + metadata: input.metadata, + time: { + created: Date.now(), + }, + }; + + pending[input.sessionID] = pending[input.sessionID] || {}; + return new Promise((resolve, reject) => { + pending[input.sessionID][info.id] = { + info, + resolve, + reject, + }; + Bus.publish(Event.Updated, info); + }); + } + + export function respond(input: { + sessionID: Info['sessionID']; + permissionID: Info['id']; + response: Response; + }) { + log.info(() => ({ message: 'response', ...input })); + const { pending, approved } = state(); + const match = pending[input.sessionID]?.[input.permissionID]; + if (!match) return; + delete pending[input.sessionID][input.permissionID]; + Bus.publish(Event.Replied, { + sessionID: input.sessionID, + permissionID: input.permissionID, + response: input.response, + }); + if (input.response === 'reject') { + match.reject( + new RejectedError( + input.sessionID, + input.permissionID, + match.info.callID, + match.info.metadata + ) + ); + return; + } + match.resolve(); + if (input.response === 'always') { + approved[input.sessionID] = approved[input.sessionID] || {}; + const approveKeys = toKeys(match.info.pattern, match.info.type); + for (const k of approveKeys) { + approved[input.sessionID][k] = true; + } + const items = pending[input.sessionID]; + if (!items) return; + for (const item of Object.values(items)) { + const itemKeys = toKeys(item.info.pattern, item.info.type); + if (covered(itemKeys, approved[input.sessionID])) { + respond({ + sessionID: item.info.sessionID, + permissionID: item.info.id, + response: input.response, + }); + } + } + } + } + + export class RejectedError extends Error { + constructor( + public readonly sessionID: string, + public readonly permissionID: string, + public readonly toolCallID?: string, + public readonly metadata?: Record, + public readonly reason?: string + ) { + super( + reason !== undefined + ? reason + : `The user rejected permission to use this specific tool call. You may try again with different parameters.` + ); + } + } + + // ─── High-level helpers used by tools ───────────────────────────────────── + + /** + * Enforce the policy for a simple (non-bash) tool type (`edit`/`webfetch`). + * Throws RejectedError on deny, asks (and may throw) on ask, returns on allow. + */ + export async function check(input: { + type: 'edit' | 'webfetch'; + title: string; + sessionID: string; + messageID: string; + callID?: string; + metadata?: Record; + }) { + const p = policy(); + const action: Action = input.type === 'edit' ? p.edit : p.webfetch; + if (action === 'allow') return; + if (action === 'deny') { + throw new RejectedError( + input.sessionID, + input.callID ?? input.messageID, + input.callID, + input.metadata, + `The ${input.type} tool is disabled in the current permission mode (${config.permissionMode ?? 'auto'}).` + ); + } + await ask({ + type: input.type, + title: input.title, + sessionID: input.sessionID, + messageID: input.messageID, + callID: input.callID, + metadata: input.metadata ?? {}, + }); + } + + /** + * Whether bash enforcement is active. In the default `auto` mode every + * pattern maps to `allow`, so callers can skip the (non-trivial) tree-sitter + * parse entirely β€” preserving zero overhead for the full-auto default. + */ + export function bashEnforced(): boolean { + const patterns = policy().bash; + return Object.values(patterns).some((a) => a !== 'allow'); + } + + /** Resolve the ask-pattern for a single command's token array. */ + function askPattern(command: string[]): string { + const head = command[0]; + const sub = command.slice(1).find((arg) => !arg.startsWith('-')); + return sub ? `${head} ${sub} *` : `${head} *`; + } + + /** + * Enforce the bash policy for the extracted command nodes of a shell line. + * + * `commands` is the list of token arrays for every `command` node parsed out + * of the shell line (e.g. via tree-sitter), so command substitutions and + * chained commands are each evaluated independently β€” faithful to OpenCode's + * bash tool. `cd` is always allowed. A `deny` match throws `RejectedError`; any + * `ask` matches are batched into a single `Permission.ask`. + */ + export async function checkBashTokens(input: { + commands: string[][]; + command: string; + sessionID: string; + messageID: string; + callID?: string; + }) { + const patterns = policy().bash; + const askPatterns = new Set(); + for (const command of input.commands) { + if (command.length === 0) continue; + if (command[0] === 'cd') continue; + const action = Wildcard.allStructured( + { head: command[0], tail: command.slice(1) }, + patterns + ) as Action | undefined; + if (action === 'deny') { + throw new RejectedError( + input.sessionID, + input.callID ?? input.messageID, + input.callID, + { command: input.command }, + `The command "${command[0]}" is not allowed in the current permission mode (${config.permissionMode ?? 'auto'}). Configuration: ${JSON.stringify( + patterns + )}` + ); + } + if (action === 'ask') { + askPatterns.add(askPattern(command)); + } + } + if (askPatterns.size > 0) { + const list = Array.from(askPatterns); + await ask({ + type: 'bash', + pattern: list, + sessionID: input.sessionID, + messageID: input.messageID, + callID: input.callID, + title: input.command, + metadata: { command: input.command, patterns: list }, + }); + } + } +} diff --git a/js/src/session/prompt.ts b/js/src/session/prompt.ts index f38b262f..64e13edd 100644 --- a/js/src/session/prompt.ts +++ b/js/src/session/prompt.ts @@ -1128,6 +1128,12 @@ export namespace SessionPrompt { ), mergeDeep(input.tools ?? {}) ); + // Enforce read-only / planning mode and explicit --disable-tools. These + // denials are applied last so they always win over agent and per-message + // tool overrides (issue #271). + for (const id of ToolRegistry.deniedTools()) { + enabledTools[id] = false; + } for (const item of await ToolRegistry.tools( input.model.providerID, input.model.modelID diff --git a/js/src/session/system.ts b/js/src/session/system.ts index 4372e087..a6c02956 100644 --- a/js/src/session/system.ts +++ b/js/src/session/system.ts @@ -2,6 +2,7 @@ import { Ripgrep } from '../file/ripgrep'; import { Global } from '../global'; import { Filesystem } from '../util/filesystem'; import { Config } from '../config/file-config'; +import { config as runtimeConfig } from '../config/config'; import { Instance } from '../project/instance'; import path from 'path'; @@ -42,6 +43,18 @@ export namespace SystemPrompt { export async function environment() { const project = Instance.project; + const readOnlyNote = runtimeConfig.readOnly + ? [ + ``, + ``, + ` You are running in read-only / planning mode. Tools that modify the`, + ` filesystem or execute shell commands (bash, edit, write, multiedit,`, + ` patch) are disabled and will be rejected if attempted. You may only`, + ` read, search, and plan. Describe the changes you would make instead`, + ` of attempting to apply them.`, + ``, + ].join('\n') + : ''; return [ [ `Here is some useful information about the environment you are running in:`, @@ -51,6 +64,7 @@ export namespace SystemPrompt { ` Platform: ${process.platform}`, ` Today's date: ${new Date().toDateString()}`, ``, + readOnlyNote, ``, ` ${ project.vcs === 'git' diff --git a/js/src/tool/bash.ts b/js/src/tool/bash.ts index 10791108..96dad028 100644 --- a/js/src/tool/bash.ts +++ b/js/src/tool/bash.ts @@ -8,6 +8,7 @@ import { lazy } from '../util/lazy'; import { Language } from 'web-tree-sitter'; import { $ } from 'bun'; import { Filesystem } from '../util/filesystem'; +import { Permission } from '../permission'; import { fileURLToPath } from 'url'; const MAX_OUTPUT_LENGTH = 30_000; @@ -71,7 +72,46 @@ export const BashTool = Tool.define('bash', { } const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT); - // No restrictions - unrestricted command execution + // Permission enforcement (issue #271). In the default `auto` mode every + // bash pattern resolves to `allow`, so we skip the tree-sitter parse + // entirely and keep zero overhead. In plan/readonly/ask modes (or with a + // `--permission` override) we parse the shell line into its individual + // command nodes and evaluate each against the bash policy: a `deny` match + // throws, `ask` matches are batched into a single JSON permission request. + if (Permission.bashEnforced()) { + const tree = await parser().then((p) => p.parse(params.command)); + if (!tree) { + throw new Error('Failed to parse command'); + } + const commands: string[][] = []; + for (const node of tree.rootNode.descendantsOfType('command')) { + if (!node) continue; + const command: string[] = []; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + if ( + child.type !== 'command_name' && + child.type !== 'word' && + child.type !== 'string' && + child.type !== 'raw_string' && + child.type !== 'concatenation' + ) { + continue; + } + command.push(child.text); + } + commands.push(command); + } + await Permission.checkBashTokens({ + commands, + command: params.command, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + }); + } + const proc = spawn(params.command, { shell: true, cwd: Instance.directory, diff --git a/js/src/tool/batch.ts b/js/src/tool/batch.ts index 5243824f..970a1b66 100644 --- a/js/src/tool/batch.ts +++ b/js/src/tool/batch.ts @@ -54,6 +54,14 @@ export const BatchTool = Tool.define('batch', async () => { ); } + // Enforce read-only / planning mode and --disable-tools even when a + // tool is invoked indirectly through batch (issue #271). + if (ToolRegistry.deniedTools().has(call.tool)) { + throw new Error( + `Tool '${call.tool}' is disabled in read-only / planning mode and cannot be executed.` + ); + } + const tool = toolMap.get(call.tool); if (!tool) { const availableToolsList = Array.from(toolMap.keys()).filter( diff --git a/js/src/tool/edit.ts b/js/src/tool/edit.ts index 26efd4bd..e35fa4f1 100644 --- a/js/src/tool/edit.ts +++ b/js/src/tool/edit.ts @@ -13,6 +13,7 @@ import { Bus } from '../bus'; import { FileTime } from '../file/time'; import { Instance } from '../project/instance'; import { Snapshot } from '../snapshot'; +import { Permission } from '../permission'; function normalizeLineEndings(text: string): string { return text.replaceAll('\r\n', '\n'); @@ -42,11 +43,21 @@ export const EditTool = Tool.define('edit', { throw new Error('oldString and newString must be different'); } - // No restrictions - unrestricted file editing const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath); + // Permission enforcement (issue #271). `auto` mode allows immediately; + // plan/readonly modes deny; `ask` mode emits a JSON permission request. + await Permission.check({ + type: 'edit', + title: filePath, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + metadata: { filePath }, + }); + let diff = ''; let contentOld = ''; let contentNew = ''; diff --git a/js/src/tool/patch.ts b/js/src/tool/patch.ts index 8ab1c86b..4dff9852 100644 --- a/js/src/tool/patch.ts +++ b/js/src/tool/patch.ts @@ -8,6 +8,7 @@ import { FileWatcher } from '../file/watcher'; import { Instance } from '../project/instance'; import { Patch } from '../patch'; import { Filesystem } from '../util/filesystem'; +import { Permission } from '../permission'; import { createTwoFilesPatch } from 'diff'; const PatchParams = z.object({ @@ -141,7 +142,24 @@ export const PatchTool = Tool.define('patch', { } } - // No restrictions - apply changes directly + // Permission enforcement (issue #271). Patching mutates the filesystem and + // is governed by the `edit` policy. `auto` allows; plan/readonly deny; + // `ask` emits a JSON permission request per affected file. + for (const change of fileChanges) { + await Permission.check({ + type: 'edit', + title: change.movePath ?? change.filePath, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + metadata: { + filePath: change.filePath, + movePath: change.movePath, + changeType: change.type, + }, + }); + } + // Apply the changes const changedFiles: string[] = []; diff --git a/js/src/tool/registry.ts b/js/src/tool/registry.ts index 362616c6..b95b0bd5 100644 --- a/js/src/tool/registry.ts +++ b/js/src/tool/registry.ts @@ -16,8 +16,44 @@ import { Instance } from '../project/instance'; import { Config } from '../config/file-config'; import { WebSearchTool } from './websearch'; import { CodeSearchTool } from './codesearch'; +import { config } from '../config/config'; export namespace ToolRegistry { + /** + * Tools that can mutate the filesystem or execute shell commands. + * These are disabled when read-only / planning mode is active so that the + * agent can only read, search and plan. This is the native, enforceable + * counterpart of agent-commander's `--read-only` flag (issue #271). + */ + export const READ_ONLY_DENIED_TOOLS = [ + 'bash', + 'edit', + 'write', + 'multiedit', + 'patch', + ] as const; + + /** + * Resolve the set of tool ids that must be denied for the current run. + * + * Combines the read-only/planning preset (`config.readOnly`) with any + * explicit `--disable-tools a,b,c` list. The result is enforced both when + * exposing tools to the model and when tools are invoked indirectly via the + * `batch` tool, so the restriction cannot be bypassed. + */ + export function deniedTools(): Set { + const denied = new Set(); + if (config.readOnly) { + for (const id of READ_ONLY_DENIED_TOOLS) denied.add(id); + } + const list = config.disableTools ?? ''; + for (const raw of list.split(',')) { + const id = raw.trim().toLowerCase(); + if (id) denied.add(id); + } + return denied; + } + export const state = Instance.state(async () => { const custom = [] as Tool.Info[]; return { custom }; diff --git a/js/src/tool/webfetch.ts b/js/src/tool/webfetch.ts index cf67772e..2a37049b 100644 --- a/js/src/tool/webfetch.ts +++ b/js/src/tool/webfetch.ts @@ -3,6 +3,7 @@ import { Tool } from './tool'; import TurndownService from 'turndown'; import DESCRIPTION from './webfetch.txt'; import { createVerboseFetch } from '../util/verbose-fetch'; +import { Permission } from '../permission'; const verboseFetch = createVerboseFetch(fetch, { caller: 'webfetch' }); @@ -33,7 +34,17 @@ export const WebFetchTool = Tool.define('webfetch', { throw new Error('URL must start with http:// or https://'); } - // No restrictions - unrestricted web fetch + // Permission enforcement (issue #271). `auto` allows; plan/readonly allow + // (webfetch is read-only); `ask` emits a JSON permission request. + await Permission.check({ + type: 'webfetch', + title: params.url, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + metadata: { url: params.url }, + }); + const timeout = Math.min( (params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT diff --git a/js/src/tool/write.ts b/js/src/tool/write.ts index c0fd78d9..ed49c79e 100644 --- a/js/src/tool/write.ts +++ b/js/src/tool/write.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { Tool } from './tool'; import DESCRIPTION from './write.txt'; import { Instance } from '../project/instance'; +import { Permission } from '../permission'; export const WriteTool = Tool.define('write', { description: DESCRIPTION, @@ -15,11 +16,22 @@ export const WriteTool = Tool.define('write', { ), }), async execute(params, ctx) { - // No restrictions - unrestricted file system access const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath); + // Permission enforcement (issue #271). Writing is governed by the same + // `edit` policy as the edit tool: `auto` allows, plan/readonly deny, + // `ask` emits a JSON permission request. + await Permission.check({ + type: 'edit', + title: filepath, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + metadata: { filePath: filepath }, + }); + const file = Bun.file(filepath); const exists = await file.exists(); diff --git a/js/tests/permission.test.ts b/js/tests/permission.test.ts new file mode 100644 index 00000000..3233dded --- /dev/null +++ b/js/tests/permission.test.ts @@ -0,0 +1,279 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { config, resetConfig } from '../src/config/config'; +import { Permission } from '../src/permission/index.ts'; +import { Instance } from '../src/project/instance.ts'; +import { Bus } from '../src/bus/index.ts'; + +/** + * Tests for the native, enforceable permission system (issue #271). + * + * Verifies the pure policy machinery (modePolicy / parseOverride / policy / + * evaluateBash / bashEnforced) and the ask/respond promise lifecycle. The + * default `auto` mode must be a no-op so existing consumers are unaffected. + */ +describe('permission system (issue #271)', () => { + const savedEnv: Record = {}; + + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('LINK_ASSISTANT_AGENT_')) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + } + resetConfig(); + // Pure policy resolution reads from the live `config` object. Reset both + // fields to their auto defaults before each test. + config.permissionMode = 'auto'; + config.permission = ''; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('LINK_ASSISTANT_AGENT_')) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(savedEnv)) { + if (value !== undefined) { + process.env[key] = value; + } + } + resetConfig(); + }); + + describe('modePolicy', () => { + test('auto allows everything (default, no behavior change)', () => { + const p = Permission.modePolicy('auto'); + expect(p.edit).toBe('allow'); + expect(p.webfetch).toBe('allow'); + expect(p.bash['*']).toBe('allow'); + }); + + test('plan denies edits, allows read-only shell, asks otherwise', () => { + const p = Permission.modePolicy('plan'); + expect(p.edit).toBe('deny'); + expect(p.webfetch).toBe('allow'); + expect(p.bash['*']).toBe('ask'); + expect(p.bash['cat*']).toBe('allow'); + expect(p.bash['git diff*']).toBe('allow'); + }); + + test('readonly denies edits and non read-only shell, never asks', () => { + const p = Permission.modePolicy('readonly'); + expect(p.edit).toBe('deny'); + expect(p.webfetch).toBe('allow'); + expect(p.bash['*']).toBe('deny'); + expect(p.bash['ls*']).toBe('allow'); + }); + + test('ask asks before every mutating tool', () => { + const p = Permission.modePolicy('ask'); + expect(p.edit).toBe('ask'); + expect(p.webfetch).toBe('ask'); + expect(p.bash['*']).toBe('ask'); + }); + }); + + describe('parseOverride', () => { + test('empty / whitespace yields no override', () => { + expect(Permission.parseOverride(undefined)).toEqual({}); + expect(Permission.parseOverride('')).toEqual({}); + expect(Permission.parseOverride(' ')).toEqual({}); + }); + + test('parses edit / webfetch / bash map', () => { + const o = Permission.parseOverride( + '{"edit":"ask","webfetch":"deny","bash":{"git push*":"ask","*":"allow"}}' + ); + expect(o.edit).toBe('ask'); + expect(o.webfetch).toBe('deny'); + expect(o.bash).toEqual({ 'git push*': 'ask', '*': 'allow' }); + }); + + test('bash as a bare string expands to a catch-all map', () => { + const o = Permission.parseOverride('{"bash":"ask"}'); + expect(o.bash).toEqual({ '*': 'ask' }); + }); + + test('throws on invalid JSON', () => { + expect(() => Permission.parseOverride('{not json')).toThrow(); + }); + + test('throws on invalid action value', () => { + expect(() => Permission.parseOverride('{"edit":"maybe"}')).toThrow(); + }); + }); + + describe('policy() resolution from config', () => { + test('defaults to full-auto allow-all', () => { + const p = Permission.policy(); + expect(p.edit).toBe('allow'); + expect(p.bash['*']).toBe('allow'); + expect(Permission.bashEnforced()).toBe(false); + }); + + test('mode + override merge (override wins, bash merged key-by-key)', () => { + config.permissionMode = 'plan'; + config.permission = '{"bash":{"git push*":"ask"},"webfetch":"deny"}'; + const p = Permission.policy(); + expect(p.edit).toBe('deny'); // from plan base + expect(p.webfetch).toBe('deny'); // from override + expect(p.bash['git push*']).toBe('ask'); // merged in + expect(p.bash['cat*']).toBe('allow'); // base preserved + expect(Permission.bashEnforced()).toBe(true); + }); + + test('readonly is enforced', () => { + config.permissionMode = 'readonly'; + expect(Permission.bashEnforced()).toBe(true); + }); + }); + + describe('evaluateBash', () => { + test('allows a chain of read-only commands in plan mode', () => { + const patterns = Permission.modePolicy('plan').bash; + const r = Permission.evaluateBash('cat a.txt && ls -la', patterns); + expect(r.action).toBe('allow'); + }); + + test('most-restrictive wins across a chain (readonly denies)', () => { + const patterns = Permission.modePolicy('readonly').bash; + const r = Permission.evaluateBash('cat a.txt && rm -rf /', patterns); + expect(r.action).toBe('deny'); + }); + + test('command substitution falls back to catch-all', () => { + const patterns = Permission.modePolicy('readonly').bash; + const r = Permission.evaluateBash('echo $(rm -rf /)', patterns); + expect(r.action).toBe('deny'); + }); + + test('output redirection falls back to catch-all', () => { + const patterns = Permission.modePolicy('readonly').bash; + const r = Permission.evaluateBash('cat a.txt > b.txt', patterns); + expect(r.action).toBe('deny'); + }); + + test('plan mode asks for an unknown command and reports a pattern', () => { + const patterns = Permission.modePolicy('plan').bash; + const r = Permission.evaluateBash('npm install', patterns); + expect(r.action).toBe('ask'); + expect(r.askPatterns.length).toBeGreaterThan(0); + }); + }); + + describe('check() enforcement', () => { + test('allow returns without asking', async () => { + config.permissionMode = 'auto'; + await Permission.check({ + type: 'edit', + title: '/tmp/x', + sessionID: 'ses_test', + messageID: 'msg_test', + }); + expect(true).toBe(true); + }); + + test('deny throws RejectedError', async () => { + config.permissionMode = 'readonly'; + await expect( + Permission.check({ + type: 'edit', + title: '/tmp/x', + sessionID: 'ses_test', + messageID: 'msg_test', + }) + ).rejects.toBeInstanceOf(Permission.RejectedError); + }); + }); + + describe('ask / respond lifecycle (JSON-driven approval)', () => { + test('publishes permission.updated and resolves on "once"', async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + config.permissionMode = 'ask'; + let requestedID: string | undefined; + const unsub = Bus.subscribe(Permission.Event.Updated, (e) => { + requestedID = e.properties.id; + // Simulate the JSON consumer replying over stdin. + Permission.respond({ + sessionID: e.properties.sessionID, + permissionID: e.properties.id, + response: 'once', + }); + }); + await Permission.check({ + type: 'edit', + title: '/tmp/x', + sessionID: 'ses_ask', + messageID: 'msg_ask', + }); + unsub(); + expect(requestedID).toBeDefined(); + }, + }); + }); + + test('"reject" rejects the pending ask with RejectedError', async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + config.permissionMode = 'ask'; + const unsub = Bus.subscribe(Permission.Event.Updated, (e) => { + Permission.respond({ + sessionID: e.properties.sessionID, + permissionID: e.properties.id, + response: 'reject', + }); + }); + await expect( + Permission.check({ + type: 'webfetch', + title: 'https://example.com', + sessionID: 'ses_rej', + messageID: 'msg_rej', + }) + ).rejects.toBeInstanceOf(Permission.RejectedError); + unsub(); + }, + }); + }); + + test('"always" auto-approves later matching asks in the same session', async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + config.permissionMode = 'ask'; + let asks = 0; + const unsub = Bus.subscribe(Permission.Event.Updated, (e) => { + asks++; + Permission.respond({ + sessionID: e.properties.sessionID, + permissionID: e.properties.id, + response: 'always', + }); + }); + const sessionID = 'ses_always'; + await Permission.check({ + type: 'edit', + title: '/tmp/a', + sessionID, + messageID: 'm1', + }); + // Second edit in the same session is covered by the "always" grant + // for the `edit` type and resolves without a new ask. + await Permission.check({ + type: 'edit', + title: '/tmp/b', + sessionID, + messageID: 'm2', + }); + unsub(); + expect(asks).toBe(1); + }, + }); + }); + }); +}); diff --git a/js/tests/read-only.test.ts b/js/tests/read-only.test.ts new file mode 100644 index 00000000..37c1dfad --- /dev/null +++ b/js/tests/read-only.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { config, initConfig, resetConfig } from '../src/config/config'; +import { ToolRegistry } from '../src/tool/registry'; + +/** + * Tests for the native, enforceable read-only / planning mode (issue #271). + * + * Verifies that `--read-only` and `--disable-tools` are parsed into config and + * that ToolRegistry.deniedTools() reports the correct set of denied tools. + */ +describe('read-only / planning mode (issue #271)', () => { + const savedEnv: Record = {}; + + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('LINK_ASSISTANT_AGENT_')) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + } + resetConfig(); + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('LINK_ASSISTANT_AGENT_')) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(savedEnv)) { + if (value !== undefined) { + process.env[key] = value; + } + } + resetConfig(); + }); + + describe('config defaults', () => { + test('readOnly defaults to false', () => { + expect(config.readOnly).toBe(false); + }); + + test('disableTools defaults to empty string', () => { + expect(config.disableTools).toBe(''); + }); + + test('no tools are denied by default', () => { + expect(ToolRegistry.deniedTools().size).toBe(0); + }); + }); + + describe('--read-only flag', () => { + test('parses --read-only into config', () => { + initConfig(['node', 'test', '--read-only']); + expect(config.readOnly).toBe(true); + }); + + test('denies all mutating tools when read-only', () => { + initConfig(['node', 'test', '--read-only']); + const denied = ToolRegistry.deniedTools(); + for (const id of ToolRegistry.READ_ONLY_DENIED_TOOLS) { + expect(denied.has(id)).toBe(true); + } + }); + + test('does not deny read-only tools when read-only', () => { + initConfig(['node', 'test', '--read-only']); + const denied = ToolRegistry.deniedTools(); + for (const id of ['read', 'list', 'glob', 'grep', 'webfetch']) { + expect(denied.has(id)).toBe(false); + } + }); + }); + + describe('LINK_ASSISTANT_AGENT_READ_ONLY env var', () => { + test('=true enables read-only mode', () => { + process.env.LINK_ASSISTANT_AGENT_READ_ONLY = 'true'; + resetConfig(); + expect(config.readOnly).toBe(true); + expect(ToolRegistry.deniedTools().has('bash')).toBe(true); + }); + + test('=1 enables read-only mode', () => { + process.env.LINK_ASSISTANT_AGENT_READ_ONLY = '1'; + resetConfig(); + expect(config.readOnly).toBe(true); + }); + }); + + describe('--disable-tools flag', () => { + test('parses a comma-separated list', () => { + initConfig(['node', 'test', '--disable-tools', 'bash,write']); + const denied = ToolRegistry.deniedTools(); + expect(denied.has('bash')).toBe(true); + expect(denied.has('write')).toBe(true); + expect(denied.has('edit')).toBe(false); + }); + + test('trims whitespace and lowercases ids', () => { + initConfig(['node', 'test', '--disable-tools', ' Bash , WRITE ']); + const denied = ToolRegistry.deniedTools(); + expect(denied.has('bash')).toBe(true); + expect(denied.has('write')).toBe(true); + }); + + test('combines with read-only', () => { + initConfig([ + 'node', + 'test', + '--read-only', + '--disable-tools', + 'webfetch', + ]); + const denied = ToolRegistry.deniedTools(); + expect(denied.has('webfetch')).toBe(true); + expect(denied.has('bash')).toBe(true); + }); + }); +}); diff --git a/rust/changelog.d/20260617_000000_permission_system.md b/rust/changelog.d/20260617_000000_permission_system.md new file mode 100644 index 00000000..abcd0f3c --- /dev/null +++ b/rust/changelog.d/20260617_000000_permission_system.md @@ -0,0 +1,8 @@ +--- +bump: minor +--- + +### Added + +- Added a `permission` module mirroring the JavaScript permission system (issue #271): `Mode` (`auto`/`plan`/`readonly`/`ask`), `Action` (`allow`/`ask`/`deny`), `Policy`, mode/override resolution, the read-only shell allowlist, structured bash evaluation, and the `permission_request` / `permission_response` JSON schemas. +- Added `--permission-mode` (env `LINK_ASSISTANT_AGENT_PERMISSION_MODE`) and `--permission` (env `LINK_ASSISTANT_AGENT_PERMISSION`) CLI flags. The default mode is `auto`, so behavior is unchanged. Invalid modes or `--permission` JSON now fail fast with a clear error. diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 2f66fc66..8f32a076 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -176,6 +176,20 @@ pub struct Args { /// Working directory #[arg(long)] pub working_directory: Option, + + /// Permission mode: "auto" (default, full autonomy, never asks), "plan" + /// (deny edits, allow read-only shell, ask otherwise), "readonly" (deny all + /// mutations, never asks), or "ask" (approve every mutating tool over JSON). + /// Env: LINK_ASSISTANT_AGENT_PERMISSION_MODE. See docs/permissions.md. + #[arg(long, default_value_t = crate::permission::default_permission_mode(), value_parser = ["auto", "plan", "readonly", "ask"])] + pub permission_mode: String, + + /// OpenCode-compatible permission override JSON, merged on top of the mode + /// (override wins; bash maps merge key-by-key). Example: + /// '{"edit":"ask","bash":{"git push*":"ask","*":"allow"}}'. + /// Env: LINK_ASSISTANT_AGENT_PERMISSION. See docs/permissions.md. + #[arg(long, default_value_t = crate::permission::default_permission())] + pub permission: String, } impl Args { @@ -222,6 +236,19 @@ impl Args { pub fn summarize_session(&self) -> bool { !self.no_summarize_session } + + /// Resolve the active permission policy from --permission-mode and + /// --permission. Returns a clear error if the mode or override is invalid, + /// so the CLI fails fast when the requested mode cannot be honored (#271 R6). + pub fn resolve_policy(&self) -> std::result::Result { + let mode = crate::permission::Mode::parse(&self.permission_mode)?; + let override_raw = if self.permission.trim().is_empty() { + None + } else { + Some(self.permission.as_str()) + }; + crate::permission::policy(mode, override_raw) + } } /// JSON input format @@ -349,6 +376,12 @@ pub async fn run(args: Args) -> Result<()> { // Resolve system messages from file args if needed let (system_message, append_system_message) = resolve_system_messages(&args)?; + // Validate the permission policy up front so an invalid --permission-mode or + // --permission override fails clearly instead of silently being ignored (#271 R6). + if let Err(e) = args.resolve_policy() { + return Err(AgentError::invalid_arguments("permission", e)); + } + // Handle --disable-stdin: requires --prompt or shows help if args.disable_stdin && args.prompt.is_none() { return Err(AgentError::invalid_arguments( @@ -569,6 +602,26 @@ async fn run_with_input( ); } + output_event( + &OutputEvent::Text { + timestamp: timestamp_ms(), + session_id: session_id.clone(), + text: format!("Permission mode: {}", args.permission_mode), + }, + args.compact_json, + ); + + if !args.permission.trim().is_empty() { + output_event( + &OutputEvent::Text { + timestamp: timestamp_ms(), + session_id: session_id.clone(), + text: format!("Permission override: {}", args.permission), + }, + args.compact_json, + ); + } + output_event( &OutputEvent::Text { timestamp: timestamp_ms(), diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 28209285..9575ed83 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -7,5 +7,6 @@ pub mod cli; pub mod defaults; pub mod error; pub mod id; +pub mod permission; pub mod tool; pub mod util; diff --git a/rust/src/permission.rs b/rust/src/permission.rs new file mode 100644 index 00000000..0e717b2f --- /dev/null +++ b/rust/src/permission.rs @@ -0,0 +1,828 @@ +//! Permission system (issue #271). +//! +//! Rust counterpart of `js/src/permission/index.ts`. Re-adds an OpenCode-style +//! permission system that is fully controllable from JSON (input and output), +//! with no TUI. +//! +//! By default the agent runs in **full auto mode** (every action is `allow`), so +//! nothing changes for existing consumers. Opt-in CLI options switch the policy: +//! +//! --permission-mode auto (default) allow everything, never ask +//! --permission-mode plan read-only planning: deny edits, allow read-only +//! shell commands, ask before anything else +//! --permission-mode readonly hard read-only: deny edits and any non +//! read-only shell command (never asks) +//! --permission-mode ask ask before every mutating tool (per-command +//! approval driven over JSON) +//! --permission '' explicit OpenCode-compatible override merged on +//! top of the mode, e.g. +//! '{"bash":{"git push*":"ask","*":"allow"},"edit":"ask"}' +//! +//! The Rust binary does not yet drive a model agent loop (tools are invoked +//! directly, not through a model), so this module provides the policy core, +//! override parsing/merging, bash evaluation, and the JSON request/response +//! schemas β€” establishing full parity with the JavaScript policy semantics and +//! the documented JSON protocol. The enforcement wiring lives in the JS agent +//! loop; once the Rust binary grows a model loop it can call `evaluate_bash` / +//! `Policy::action_for` at the same tool boundaries. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Env var that overrides the default permission mode. +pub const PERMISSION_MODE_ENV: &str = "LINK_ASSISTANT_AGENT_PERMISSION_MODE"; + +/// Env var that supplies the default `--permission` JSON override. +pub const PERMISSION_ENV: &str = "LINK_ASSISTANT_AGENT_PERMISSION"; + +/// Default permission mode when nothing is supplied. +pub const DEFAULT_PERMISSION_MODE: &str = "auto"; + +/// Default permission mode, honoring the env override (used as the clap default). +pub fn default_permission_mode_from_env(getenv: impl Fn(&str) -> Option) -> String { + getenv(PERMISSION_MODE_ENV) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_PERMISSION_MODE.to_string()) +} + +/// Default permission mode, reading the process environment. +pub fn default_permission_mode() -> String { + default_permission_mode_from_env(|key| std::env::var(key).ok()) +} + +/// Default `--permission` JSON override, honoring the env var (clap default). +pub fn default_permission_from_env(getenv: impl Fn(&str) -> Option) -> String { + getenv(PERMISSION_ENV) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_default() +} + +/// Default `--permission` JSON override, reading the process environment. +pub fn default_permission() -> String { + default_permission_from_env(|key| std::env::var(key).ok()) +} + +// ─── Policy types ─────────────────────────────────────────────────────────── + +/// A single permission action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Action { + Allow, + Ask, + Deny, +} + +impl Action { + pub fn as_str(&self) -> &'static str { + match self { + Action::Allow => "allow", + Action::Ask => "ask", + Action::Deny => "deny", + } + } + + /// Parse an action from a string, mirroring the JS `Action` zod enum. + pub fn parse(value: &str) -> Result { + match value { + "allow" => Ok(Action::Allow), + "ask" => Ok(Action::Ask), + "deny" => Ok(Action::Deny), + other => Err(format!( + "Invalid permission action \"{}\": expected one of allow, ask, deny", + other + )), + } + } + + /// Numeric restrictiveness used to pick the strongest action in a chain. + fn restrictiveness(&self) -> u8 { + match self { + Action::Allow => 0, + Action::Ask => 1, + Action::Deny => 2, + } + } + + fn more_restrictive(self, other: Action) -> Action { + if other.restrictiveness() > self.restrictiveness() { + other + } else { + self + } + } +} + +/// Permission mode (base policy selector). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + Auto, + Plan, + Readonly, + Ask, +} + +impl Mode { + pub fn as_str(&self) -> &'static str { + match self { + Mode::Auto => "auto", + Mode::Plan => "plan", + Mode::Readonly => "readonly", + Mode::Ask => "ask", + } + } + + /// Parse a mode from a string, mirroring the JS `Mode` zod enum. + pub fn parse(value: &str) -> Result { + match value { + "auto" => Ok(Mode::Auto), + "plan" => Ok(Mode::Plan), + "readonly" => Ok(Mode::Readonly), + "ask" => Ok(Mode::Ask), + other => Err(format!( + "Invalid permission mode \"{}\": expected one of auto, plan, readonly, ask", + other + )), + } + } +} + +/// Resolved permission policy. `bash` is a pattern map (OpenCode-compatible). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Policy { + pub edit: Action, + pub bash: BTreeMap, + pub webfetch: Action, +} + +/// A partial policy parsed from the `--permission` JSON override. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PartialPolicy { + pub edit: Option, + pub bash: Option>, + pub webfetch: Option, +} + +/// Read-only shell allowlist. Commands matching one of these patterns are safe +/// to run in plan/readonly mode; everything else falls back to `fallback` +/// (`ask` for plan, `deny` for readonly). +pub fn readonly_bash(fallback: Action) -> BTreeMap { + let mut map = BTreeMap::new(); + let allow = &[ + "cat*", + "cd*", + "cut*", + "date*", + "df*", + "diff*", + "dirname*", + "du*", + "echo*", + "env", + "file *", + "find *", + "git diff*", + "git log*", + "git show*", + "git status*", + "git branch", + "git branch -v", + "grep*", + "head*", + "hostname*", + "less*", + "ls*", + "more*", + "printenv*", + "pwd*", + "readlink*", + "realpath*", + "rg*", + "sort*", + "stat*", + "tail*", + "tree*", + "uname*", + "uniq*", + "wc*", + "whereis*", + "which*", + "whoami*", + ]; + for pattern in allow { + map.insert((*pattern).to_string(), Action::Allow); + } + // Read-only command families that have destructive variants: + let fallbacks = &[ + "find * -delete*", + "find * -exec*", + "find * -execdir*", + "find * -fprint*", + "find * -fls*", + "find * -fprintf*", + "find * -ok*", + "sort -o*", + "sort --output*", + "tree -o*", + ]; + for pattern in fallbacks { + map.insert((*pattern).to_string(), fallback); + } + // Catch-all: + map.insert("*".to_string(), fallback); + map +} + +/// Base policy for a mode, before any explicit `--permission` override. +pub fn mode_policy(mode: Mode) -> Policy { + match mode { + Mode::Plan => Policy { + edit: Action::Deny, + bash: readonly_bash(Action::Ask), + webfetch: Action::Allow, + }, + Mode::Readonly => Policy { + edit: Action::Deny, + bash: readonly_bash(Action::Deny), + webfetch: Action::Allow, + }, + Mode::Ask => { + let mut bash = BTreeMap::new(); + bash.insert("*".to_string(), Action::Ask); + Policy { + edit: Action::Ask, + bash, + webfetch: Action::Ask, + } + } + Mode::Auto => { + let mut bash = BTreeMap::new(); + bash.insert("*".to_string(), Action::Allow); + Policy { + edit: Action::Allow, + bash, + webfetch: Action::Allow, + } + } + } +} + +/// Parse the raw `--permission` JSON string into a partial policy. +pub fn parse_override(raw: Option<&str>) -> Result { + let raw = match raw { + Some(value) if !value.trim().is_empty() => value, + _ => return Ok(PartialPolicy::default()), + }; + let parsed: serde_json::Value = + serde_json::from_str(raw).map_err(|e| format!("Invalid --permission JSON: {}", e))?; + let obj = match parsed { + serde_json::Value::Object(map) => map, + _ => return Err("Invalid --permission JSON: expected an object".to_string()), + }; + + let mut out = PartialPolicy::default(); + + if let Some(edit) = obj.get("edit") { + out.edit = Some(parse_action_value(edit)?); + } + if let Some(webfetch) = obj.get("webfetch") { + out.webfetch = Some(parse_action_value(webfetch)?); + } + if let Some(bash) = obj.get("bash") { + match bash { + serde_json::Value::String(s) => { + let mut map = BTreeMap::new(); + map.insert("*".to_string(), Action::parse(s)?); + out.bash = Some(map); + } + serde_json::Value::Object(map) => { + let mut bash_map = BTreeMap::new(); + for (pattern, value) in map { + bash_map.insert(pattern.clone(), parse_action_value(value)?); + } + out.bash = Some(bash_map); + } + _ => { + return Err( + "Invalid --permission JSON: \"bash\" must be a string or object".to_string(), + ) + } + } + } + + Ok(out) +} + +fn parse_action_value(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::String(s) => Action::parse(s), + _ => Err("Invalid permission action: expected a string".to_string()), + } +} + +/// Resolve the active policy from a mode and an optional `--permission` override. +/// Mode defines the base policy and the JSON override is merged on top (override +/// wins; bash maps are merged key-by-key). +pub fn policy(mode: Mode, override_raw: Option<&str>) -> Result { + let base = mode_policy(mode); + let over = parse_override(override_raw)?; + let mut bash = base.bash; + if let Some(over_bash) = over.bash { + for (pattern, action) in over_bash { + bash.insert(pattern, action); + } + } + Ok(Policy { + edit: over.edit.unwrap_or(base.edit), + webfetch: over.webfetch.unwrap_or(base.webfetch), + bash, + }) +} + +impl Policy { + /// Action for a simple (non-bash) tool type (`edit`/`webfetch`). + pub fn action_for(&self, tool_type: &str) -> Action { + match tool_type { + "edit" => self.edit, + "webfetch" => self.webfetch, + _ => Action::Allow, + } + } + + /// Whether bash enforcement is active. In the default `auto` mode every + /// pattern maps to `allow`, so callers can skip the command parse entirely β€” + /// preserving zero overhead for the full-auto default. + pub fn bash_enforced(&self) -> bool { + self.bash.values().any(|a| *a != Action::Allow) + } +} + +// ─── Wildcard matching (port of js/src/util/wildcard.ts) ───────────────────── + +/// Match a string against a glob pattern (`*` = any sequence, `?` = any char). +pub fn wildcard_match(input: &str, pattern: &str) -> bool { + let mut regex = String::with_capacity(pattern.len() * 2 + 2); + regex.push('^'); + for ch in pattern.chars() { + match ch { + '*' => regex.push_str(".*"), + '?' => regex.push('.'), + // Escape regex metacharacters (mirror the JS escape set). + '.' | '+' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\' => { + regex.push('\\'); + regex.push(ch); + } + other => regex.push(other), + } + } + regex.push('$'); + // The JS matcher uses the `s` (dotAll) flag. + match regex::RegexBuilder::new(®ex) + .dot_matches_new_line(true) + .build() + { + Ok(re) => re.is_match(input), + Err(_) => false, + } +} + +fn match_sequence(items: &[String], patterns: &[&str]) -> bool { + if patterns.is_empty() { + return true; + } + let (pattern, rest) = (patterns[0], &patterns[1..]); + if pattern == "*" { + return match_sequence(items, rest); + } + for i in 0..items.len() { + if wildcard_match(&items[i], pattern) && match_sequence(&items[i + 1..], rest) { + return true; + } + } + false +} + +/// Structured match: `head` against the first pattern token, `tail` against the +/// rest. Patterns are tried sorted by (length asc, key asc) so the longest / +/// last matching rule wins β€” faithful to the JS `Wildcard.allStructured`. +pub fn match_structured( + head: &str, + tail: &[String], + patterns: &BTreeMap, +) -> Option { + let mut sorted: Vec<(&String, &Action)> = patterns.iter().collect(); + sorted.sort_by(|a, b| a.0.len().cmp(&b.0.len()).then_with(|| a.0.cmp(b.0))); + let mut result = None; + for (pattern, value) in sorted { + let parts: Vec<&str> = pattern.split_whitespace().collect(); + if parts.is_empty() { + continue; + } + if !wildcard_match(head, parts[0]) { + continue; + } + if parts.len() == 1 || match_sequence(tail, &parts[1..]) { + result = Some(*value); + } + } + result +} + +// ─── Bash evaluation (port of evaluateBash) ────────────────────────────────── + +fn tokenize(segment: &str) -> Vec { + segment.split_whitespace().map(|s| s.to_string()).collect() +} + +/// Result of evaluating a shell command against a bash policy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BashEvaluation { + pub action: Action, + pub ask_patterns: Vec, +} + +/// Evaluate a shell command against a bash pattern map. +/// +/// A command is only `allow`ed when every segment (split on `;`, `&&`, `||`, +/// `|`) matches an allow pattern and the command contains no command +/// substitution or output redirection. Otherwise it falls back to the catch-all +/// action (`*`). +pub fn evaluate_bash(command: &str, patterns: &BTreeMap) -> BashEvaluation { + let fallback = patterns.get("*").copied().unwrap_or(Action::Allow); + let mut ask_patterns: Vec = Vec::new(); + + // Command substitution or output redirection can hide arbitrary writes. + let has_substitution = command.contains("$(") || command.contains('`'); + let has_redirection = command.contains('>'); + if has_substitution || has_redirection { + if fallback == Action::Ask { + ask_patterns.push(command.to_string()); + } + return BashEvaluation { + action: fallback, + ask_patterns, + }; + } + + let segments: Vec<&str> = split_segments(command); + if segments.is_empty() { + return BashEvaluation { + action: fallback, + ask_patterns: Vec::new(), + }; + } + + let mut action = Action::Allow; + for segment in segments { + let tokens = tokenize(segment); + if tokens.is_empty() { + continue; + } + let matched = match_structured(&tokens[0], &tokens[1..], patterns); + let seg_action = matched.unwrap_or(fallback); + action = action.more_restrictive(seg_action); + if seg_action == Action::Ask { + let sub = tokens[1..].iter().find(|arg| !arg.starts_with('-')); + let pattern = match sub { + Some(sub) => format!("{} {} *", tokens[0], sub), + None => format!("{} *", tokens[0]), + }; + if !ask_patterns.contains(&pattern) { + ask_patterns.push(pattern); + } + } + } + + BashEvaluation { + action, + ask_patterns, + } +} + +/// Split a shell line on the top-level separators `&&`, `||`, `;`, `|`. +fn split_segments(command: &str) -> Vec<&str> { + let bytes = command.as_bytes(); + let mut segments = Vec::new(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + let two = &command[i..(i + 2).min(command.len())]; + if two == "&&" || two == "||" { + segments.push(command[start..i].trim()); + i += 2; + start = i; + continue; + } + let c = bytes[i]; + if c == b';' || c == b'|' { + segments.push(command[start..i].trim()); + i += 1; + start = i; + continue; + } + i += 1; + } + segments.push(command[start..].trim()); + segments.into_iter().filter(|s| !s.is_empty()).collect() +} + +// ─── JSON protocol structs ─────────────────────────────────────────────────── + +/// `permission_request` event emitted to stdout when a tool needs approval. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PermissionRequest { + #[serde(rename = "type")] + pub event_type: String, + pub timestamp: u64, + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "permissionID")] + pub permission_id: String, + #[serde(rename = "callID", skip_serializing_if = "Option::is_none")] + pub call_id: Option, + pub tool: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option>, + pub title: String, + pub metadata: serde_json::Value, +} + +impl PermissionRequest { + pub fn new( + timestamp: u64, + session_id: impl Into, + permission_id: impl Into, + tool: impl Into, + title: impl Into, + ) -> Self { + PermissionRequest { + event_type: "permission_request".to_string(), + timestamp, + session_id: session_id.into(), + permission_id: permission_id.into(), + call_id: None, + tool: tool.into(), + pattern: None, + title: title.into(), + metadata: serde_json::Value::Object(Default::default()), + } + } +} + +/// A permission response value sent from the consumer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Response { + Once, + Always, + Reject, +} + +impl Response { + pub fn parse(value: &str) -> Result { + match value { + "once" => Ok(Response::Once), + "always" => Ok(Response::Always), + "reject" => Ok(Response::Reject), + other => Err(format!( + "Invalid permission response \"{}\": expected one of once, always, reject", + other + )), + } + } +} + +/// `permission_response` frame read from stdin to resolve a pending request. +/// `permissionID` may also be supplied as `permission_id` (both accepted). +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct PermissionResponse { + #[serde(rename = "type")] + pub event_type: String, + #[serde(rename = "permissionID", alias = "permission_id")] + pub permission_id: String, + pub response: Response, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn auto() -> Policy { + policy(Mode::Auto, None).unwrap() + } + + #[test] + fn mode_policy_auto_allows_everything() { + let p = mode_policy(Mode::Auto); + assert_eq!(p.edit, Action::Allow); + assert_eq!(p.webfetch, Action::Allow); + assert_eq!(p.bash.get("*"), Some(&Action::Allow)); + assert!(!p.bash_enforced()); + } + + #[test] + fn mode_policy_plan_denies_edit_asks_unknown_bash() { + let p = mode_policy(Mode::Plan); + assert_eq!(p.edit, Action::Deny); + assert_eq!(p.webfetch, Action::Allow); + assert_eq!(p.bash.get("*"), Some(&Action::Ask)); + assert_eq!(p.bash.get("cat*"), Some(&Action::Allow)); + assert!(p.bash_enforced()); + } + + #[test] + fn mode_policy_readonly_denies_unknown_bash() { + let p = mode_policy(Mode::Readonly); + assert_eq!(p.edit, Action::Deny); + assert_eq!(p.bash.get("*"), Some(&Action::Deny)); + assert_eq!(p.bash.get("ls*"), Some(&Action::Allow)); + assert!(p.bash_enforced()); + } + + #[test] + fn mode_policy_ask_asks_everything() { + let p = mode_policy(Mode::Ask); + assert_eq!(p.edit, Action::Ask); + assert_eq!(p.webfetch, Action::Ask); + assert_eq!(p.bash.get("*"), Some(&Action::Ask)); + } + + #[test] + fn parse_override_empty_is_default() { + assert_eq!(parse_override(None).unwrap(), PartialPolicy::default()); + assert_eq!(parse_override(Some("")).unwrap(), PartialPolicy::default()); + assert_eq!( + parse_override(Some(" ")).unwrap(), + PartialPolicy::default() + ); + } + + #[test] + fn parse_override_edit_webfetch_bash_map() { + let over = parse_override(Some( + r#"{"edit":"ask","webfetch":"deny","bash":{"git push*":"ask","*":"allow"}}"#, + )) + .unwrap(); + assert_eq!(over.edit, Some(Action::Ask)); + assert_eq!(over.webfetch, Some(Action::Deny)); + let bash = over.bash.unwrap(); + assert_eq!(bash.get("git push*"), Some(&Action::Ask)); + assert_eq!(bash.get("*"), Some(&Action::Allow)); + } + + #[test] + fn parse_override_bare_bash_string() { + let over = parse_override(Some(r#"{"bash":"ask"}"#)).unwrap(); + let bash = over.bash.unwrap(); + assert_eq!(bash.get("*"), Some(&Action::Ask)); + } + + #[test] + fn parse_override_invalid_json_errors() { + assert!(parse_override(Some("{not json")).is_err()); + } + + #[test] + fn parse_override_invalid_action_errors() { + assert!(parse_override(Some(r#"{"edit":"maybe"}"#)).is_err()); + } + + #[test] + fn parse_override_non_object_errors() { + assert!(parse_override(Some("[1,2,3]")).is_err()); + } + + #[test] + fn policy_default_is_full_auto() { + let p = auto(); + assert_eq!(p.edit, Action::Allow); + assert_eq!(p.action_for("edit"), Action::Allow); + assert_eq!(p.action_for("webfetch"), Action::Allow); + assert!(!p.bash_enforced()); + } + + #[test] + fn policy_merges_override_on_top_of_mode() { + let p = policy( + Mode::Plan, + Some(r#"{"edit":"ask","bash":{"npm*":"allow"}}"#), + ) + .unwrap(); + // override wins for edit + assert_eq!(p.edit, Action::Ask); + // merged key-by-key: base plan bash still present + assert_eq!(p.bash.get("cat*"), Some(&Action::Allow)); + assert_eq!(p.bash.get("npm*"), Some(&Action::Allow)); + assert_eq!(p.bash.get("*"), Some(&Action::Ask)); + } + + #[test] + fn evaluate_bash_readonly_chain_allows() { + let patterns = readonly_bash(Action::Deny); + let res = evaluate_bash("ls -la && cat file.txt", &patterns); + assert_eq!(res.action, Action::Allow); + assert!(res.ask_patterns.is_empty()); + } + + #[test] + fn evaluate_bash_most_restrictive_wins() { + let patterns = readonly_bash(Action::Deny); + let res = evaluate_bash("cat file.txt && rm file.txt", &patterns); + assert_eq!(res.action, Action::Deny); + } + + #[test] + fn evaluate_bash_command_substitution_falls_back() { + let patterns = readonly_bash(Action::Deny); + let res = evaluate_bash("echo $(rm -rf /)", &patterns); + assert_eq!(res.action, Action::Deny); + } + + #[test] + fn evaluate_bash_redirection_falls_back() { + let patterns = readonly_bash(Action::Deny); + let res = evaluate_bash("cat file.txt > out.txt", &patterns); + assert_eq!(res.action, Action::Deny); + } + + #[test] + fn evaluate_bash_plan_asks_unknown() { + let patterns = readonly_bash(Action::Ask); + let res = evaluate_bash("npm install", &patterns); + assert_eq!(res.action, Action::Ask); + assert_eq!(res.ask_patterns, vec!["npm install *".to_string()]); + } + + #[test] + fn evaluate_bash_find_delete_falls_back() { + let patterns = readonly_bash(Action::Deny); + let res = evaluate_bash("find . -name '*.tmp' -delete", &patterns); + assert_eq!(res.action, Action::Deny); + } + + #[test] + fn evaluate_bash_plain_find_allows() { + let patterns = readonly_bash(Action::Deny); + let res = evaluate_bash("find . -name '*.rs'", &patterns); + assert_eq!(res.action, Action::Allow); + } + + #[test] + fn wildcard_match_basic() { + assert!(wildcard_match("git push origin", "git push*")); + assert!(!wildcard_match("git pull", "git push*")); + assert!(wildcard_match("anything", "*")); + } + + #[test] + fn permission_request_serializes_with_camel_ids() { + let mut req = PermissionRequest::new(123, "ses_a", "per_b", "bash", "npm install"); + req.pattern = Some(vec!["npm install *".to_string()]); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["type"], "permission_request"); + assert_eq!(json["sessionID"], "ses_a"); + assert_eq!(json["permissionID"], "per_b"); + assert_eq!(json["tool"], "bash"); + assert_eq!(json["pattern"][0], "npm install *"); + // callID omitted when None + assert!(json.get("callID").is_none()); + } + + #[test] + fn permission_response_parses_both_id_keys() { + let a: PermissionResponse = serde_json::from_str( + r#"{"type":"permission_response","permissionID":"per_x","response":"once"}"#, + ) + .unwrap(); + assert_eq!(a.permission_id, "per_x"); + assert_eq!(a.response, Response::Once); + + let b: PermissionResponse = serde_json::from_str( + r#"{"type":"permission_response","permission_id":"per_y","response":"always"}"#, + ) + .unwrap(); + assert_eq!(b.permission_id, "per_y"); + assert_eq!(b.response, Response::Always); + } + + #[test] + fn response_parse_rejects_unknown() { + assert!(Response::parse("maybe").is_err()); + assert_eq!(Response::parse("reject").unwrap(), Response::Reject); + } + + #[test] + fn mode_and_action_parse_roundtrip() { + for m in ["auto", "plan", "readonly", "ask"] { + assert_eq!(Mode::parse(m).unwrap().as_str(), m); + } + for a in ["allow", "ask", "deny"] { + assert_eq!(Action::parse(a).unwrap().as_str(), a); + } + assert!(Mode::parse("nope").is_err()); + assert!(Action::parse("nope").is_err()); + } +}