From 8002120844c9b5dd22b90bebf81d4ec4d001d76e Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Jun 2026 13:03:06 +0000 Subject: [PATCH 1/5] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-assistant/agent/issues/271 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..06f5a23 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-17T13:03:06.064Z for PR creation at branch issue-271-3f282f6e5862 for issue https://github.com/link-assistant/agent/issues/271 \ No newline at end of file From 1bd2427cd86a119d63d553dabe5681a15631a3ad Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Jun 2026 13:12:06 +0000 Subject: [PATCH 2/5] feat: add native enforceable read-only / planning mode (#271) Add --read-only and --disable-tools flags (plus LINK_ASSISTANT_AGENT_READ_ONLY and LINK_ASSISTANT_AGENT_DISABLE_TOOLS env vars) that disable filesystem-mutating and shell tools (bash, edit, write, multiedit, patch). Enforcement is applied at tool resolution (so the model never sees denied tools) and inside the batch tool (so denied tools cannot be invoked indirectly). A read-only note is added to the environment system prompt. This makes agent-commander's uniform --read-only flag enforceable for the agent tool. --- .gitkeep | 1 - README.md | 5 ++ TOOLS.md | 44 ++++++++++++ js/.changeset/read-only-mode.md | 9 +++ js/README.md | 12 ++++ js/src/cli/run-options.js | 11 +++ js/src/config/config.ts | 18 +++++ js/src/session/prompt.ts | 6 ++ js/src/session/system.ts | 14 ++++ js/src/tool/batch.ts | 8 +++ js/src/tool/registry.ts | 36 ++++++++++ js/tests/read-only.test.ts | 119 ++++++++++++++++++++++++++++++++ 12 files changed, 282 insertions(+), 1 deletion(-) delete mode 100644 .gitkeep create mode 100644 js/.changeset/read-only-mode.md create mode 100644 js/tests/read-only.test.ts diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 06f5a23..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-17T13:03:06.064Z for PR creation at branch issue-271-3f282f6e5862 for issue https://github.com/link-assistant/agent/issues/271 \ No newline at end of file diff --git a/README.md b/README.md index 622f0b7..068276a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ > - ⚠️ **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). ## Implementations diff --git a/TOOLS.md b/TOOLS.md index 7bf6d6d..39d9c2b 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -105,6 +105,50 @@ 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`. + ## Testing ### Run All Tool Tests diff --git a/js/.changeset/read-only-mode.md b/js/.changeset/read-only-mode.md new file mode 100644 index 0000000..d885e19 --- /dev/null +++ b/js/.changeset/read-only-mode.md @@ -0,0 +1,9 @@ +--- +'@link-assistant/agent': minor +--- + +Add a native, enforceable read-only / planning mode (`--read-only`) plus a general `--disable-tools ` flag (issue #271). + +`--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`) allows disabling 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. diff --git a/js/README.md b/js/README.md index 32664f8..e7dbaa4 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/run-options.js b/js/src/cli/run-options.js index 97ac414..a0af478 100644 --- a/js/src/cli/run-options.js +++ b/js/src/cli/run-options.js @@ -211,5 +211,16 @@ 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.', }); } diff --git a/js/src/config/config.ts b/js/src/config/config.ts index b779f19..0eb2b38 100644 --- a/js/src/config/config.ts +++ b/js/src/config/config.ts @@ -52,6 +52,8 @@ export interface AgentConfig { mcpDefaultToolCallTimeout: number; mcpMaxToolCallTimeout: number; verifyImagesAtReadTool: boolean; + readOnly: boolean; + disableTools: string; } // Fallback helpers for when config is not yet initialized (early imports/tests) @@ -132,6 +134,8 @@ 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') ?? '', }; } @@ -240,6 +244,18 @@ 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', ''), }); } @@ -292,6 +308,8 @@ 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 ?? '', }); // Propagate verbose to env var for subprocess resilience (issue #227). diff --git a/js/src/session/prompt.ts b/js/src/session/prompt.ts index f38b262..64e13ed 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 4372e08..a6c0295 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/batch.ts b/js/src/tool/batch.ts index 5243824..970a1b6 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/registry.ts b/js/src/tool/registry.ts index 362616c..b95b0bd 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/tests/read-only.test.ts b/js/tests/read-only.test.ts new file mode 100644 index 0000000..37c1dfa --- /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); + }); + }); +}); From ca3690a8d2332774549ac9d1ea4bf201fdcd390e Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Jun 2026 14:16:42 +0000 Subject: [PATCH 3/5] feat(js): native JSON-driven permission system (plan/readonly/ask modes) Port OpenCode's permission system back into the JS agent, driven entirely over JSON (no TUI). Default --permission-mode auto preserves full auto behavior. plan/readonly/ask modes plus an OpenCode-compatible --permission override enforce read-only / planning / per-command approval. - js/src/permission/index.ts: Permission namespace (policy, modes, ask/respond) - tool enforcement in bash/edit/write/patch/webfetch - JSON I/O: permission_request event + permission_response frame (text & stream-json) - CLI flags --permission-mode / --permission + config + env vars - js/tests/permission.test.ts (22 tests) - docs/permissions.md + docs/case-studies/issue-271 (analysis, research, data) Refs #271 --- docs/case-studies/issue-271/README.md | 157 +++++ .../case-studies/issue-271/research-data.json | 91 +++ .../research-permissions-landscape.md | 253 ++++++++ docs/permissions.md | 182 ++++++ js/src/cli/continuous-mode.js | 27 + js/src/cli/event-handler.js | 21 + js/src/cli/input-queue.js | 70 ++- js/src/cli/run-options.js | 12 + js/src/config/config.ts | 19 + js/src/permission/index.ts | 564 ++++++++++++++++++ js/src/tool/bash.ts | 42 +- js/src/tool/edit.ts | 13 +- js/src/tool/patch.ts | 20 +- js/src/tool/webfetch.ts | 13 +- js/src/tool/write.ts | 14 +- js/tests/permission.test.ts | 279 +++++++++ 16 files changed, 1765 insertions(+), 12 deletions(-) create mode 100644 docs/case-studies/issue-271/README.md create mode 100644 docs/case-studies/issue-271/research-data.json create mode 100644 docs/case-studies/issue-271/research-permissions-landscape.md create mode 100644 docs/permissions.md create mode 100644 js/src/permission/index.ts create mode 100644 js/tests/permission.test.ts diff --git a/docs/case-studies/issue-271/README.md b/docs/case-studies/issue-271/README.md new file mode 100644 index 0000000..d473460 --- /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 0000000..fd8fdb7 --- /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 0000000..eaed17e --- /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 0000000..be7715c --- /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/src/cli/continuous-mode.js b/js/src/cli/continuous-mode.js index 2e0871a..72c4452 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 f79372a..04e56ef 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 caa41bc..82af985 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 a0af478..8b52cfd 100644 --- a/js/src/cli/run-options.js +++ b/js/src/cli/run-options.js @@ -222,5 +222,17 @@ export function buildRunOptions(yargs, defaultOptions = {}) { 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 0eb2b38..4a1d5d1 100644 --- a/js/src/config/config.ts +++ b/js/src/config/config.ts @@ -54,6 +54,8 @@ export interface AgentConfig { verifyImagesAtReadTool: boolean; readOnly: boolean; disableTools: string; + permissionMode: string; + permission: string; } // Fallback helpers for when config is not yet initialized (early imports/tests) @@ -136,6 +138,8 @@ function defaultConfig(): AgentConfig { 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') ?? '', }; } @@ -256,6 +260,19 @@ function buildAgentConfigOptions({ 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', ''), }); } @@ -310,6 +327,8 @@ export function initConfig(argv?: string[]): AgentConfig { 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 0000000..6d10f3e --- /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/tool/bash.ts b/js/src/tool/bash.ts index 1079110..96dad02 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/edit.ts b/js/src/tool/edit.ts index 26efd4b..e35fa4f 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 8ab1c86..4dff985 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/webfetch.ts b/js/src/tool/webfetch.ts index cf67772..2a37049 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 c0fd78d..ed49c79 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 0000000..3233dde --- /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); + }, + }); + }); + }); +}); From 9b91bc3870abac36042da87f1cfea70c3b8bb04e Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Jun 2026 14:25:08 +0000 Subject: [PATCH 4/5] feat(rust): port permission system + docs/changeset for both impls (#271) - rust/src/permission.rs: Mode/Action/Policy, mode policy, --permission override parse+merge, read-only bash allowlist, structured wildcard matcher, evaluate_bash, and permission_request/permission_response JSON schemas, with 24 unit tests. Mirrors js/src/permission/index.ts. - rust/src/cli.rs: --permission-mode / --permission flags (env-backed), early policy validation (fail clearly on invalid mode/JSON), verbose output line. - README.md / TOOLS.md: document the permission system + link docs/permissions.md. - js/.changeset + rust/changelog.d: version-bump fragments. --- README.md | 6 + TOOLS.md | 26 + js/.changeset/permission-system.md | 14 + .../20260617_000000_permission_system.md | 8 + rust/src/cli.rs | 53 ++ rust/src/lib.rs | 1 + rust/src/permission.rs | 828 ++++++++++++++++++ 7 files changed, 936 insertions(+) create mode 100644 js/.changeset/permission-system.md create mode 100644 rust/changelog.d/20260617_000000_permission_system.md create mode 100644 rust/src/permission.rs diff --git a/README.md b/README.md index 068276a..3cfc3b5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ > 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 39d9c2b..56b06a2 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -149,6 +149,32 @@ 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/js/.changeset/permission-system.md b/js/.changeset/permission-system.md new file mode 100644 index 0000000..da75fea --- /dev/null +++ b/js/.changeset/permission-system.md @@ -0,0 +1,14 @@ +--- +'@link-assistant/agent': minor +--- + +Add a native, enforceable permission system ported from OpenCode, fully controllable over JSON with no TUI (issue #271). + +The agent now supports `--permission-mode ` (env `LINK_ASSISTANT_AGENT_PERMISSION_MODE`) and 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/rust/changelog.d/20260617_000000_permission_system.md b/rust/changelog.d/20260617_000000_permission_system.md new file mode 100644 index 0000000..abcd0f3 --- /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 2f66fc6..8f32a07 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 2820928..9575ed8 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 0000000..0e717b2 --- /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()); + } +} From 047814417b6287989bd686f7ecad05095ca058ae Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Jun 2026 14:26:35 +0000 Subject: [PATCH 5/5] chore: consolidate into a single changeset (CI requires exactly one) (#271) --- js/.changeset/permission-system.md | 14 -------------- js/.changeset/read-only-mode.md | 13 ++++++++++--- 2 files changed, 10 insertions(+), 17 deletions(-) delete mode 100644 js/.changeset/permission-system.md diff --git a/js/.changeset/permission-system.md b/js/.changeset/permission-system.md deleted file mode 100644 index da75fea..0000000 --- a/js/.changeset/permission-system.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@link-assistant/agent': minor ---- - -Add a native, enforceable permission system ported from OpenCode, fully controllable over JSON with no TUI (issue #271). - -The agent now supports `--permission-mode ` (env `LINK_ASSISTANT_AGENT_PERMISSION_MODE`) and 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/.changeset/read-only-mode.md b/js/.changeset/read-only-mode.md index d885e19..c0670de 100644 --- a/js/.changeset/read-only-mode.md +++ b/js/.changeset/read-only-mode.md @@ -2,8 +2,15 @@ '@link-assistant/agent': minor --- -Add a native, enforceable read-only / planning mode (`--read-only`) plus a general `--disable-tools ` flag (issue #271). +Add a native, enforceable permission system, ported from OpenCode and fully controllable over JSON with no TUI (issue #271). -`--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`) allows disabling an explicit set of tools. +**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. -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.