From e0cedd4205d3da57e5f8f81fcdf93ecd3782bb90 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Jun 2026 15:20:23 +0000 Subject: [PATCH 1/2] 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-commander/issues/39 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..13d525b --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-17T15:20:23.237Z for PR creation at branch issue-39-15508d68b466 for issue https://github.com/link-assistant/agent-commander/issues/39 \ No newline at end of file From de0cfbf18c72780393f09962a28fd1632ad4eb9b Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 17 Jun 2026 15:37:30 +0000 Subject: [PATCH 2/2] feat: map agent --read-only/--plan-only to native --permission-mode Map the agent tool's --read-only to --permission-mode readonly and --plan-only to --permission-mode plan (agent v0.24.0), matching agent's own distinction between the two modes. Stop rejecting --tool agent --read-only and surface typed permissionMode/permission passthrough. Closes #39 --- README.md | 20 ++++-- docs/common-concepts.md | 4 +- js/.changeset/agent-permission-mode.md | 5 ++ js/bin/start-agent.mjs | 1 + js/src/cli-parser.mjs | 5 +- js/src/command-builder.mjs | 15 +++-- js/src/index.mjs | 5 +- js/src/tools/agent.mjs | 32 ++++++++- js/test/command-builder.test.mjs | 29 +++++++- js/test/tools.test.mjs | 38 +++++++++++ .../20260617_120000_agent_permission_mode.md | 7 ++ rust/src/bin/start_agent.rs | 1 + rust/src/cli_parser.rs | 6 +- rust/src/command_builder.rs | 66 ++++++++++++++++--- rust/src/lib.rs | 7 +- rust/src/tools/agent.rs | 42 ++++++++++-- 16 files changed, 245 insertions(+), 38 deletions(-) create mode 100644 js/.changeset/agent-permission-mode.md create mode 100644 rust/changelog.d/20260617_120000_agent_permission_mode.md diff --git a/README.md b/README.md index 8c0e3aa..747366a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Language-specific package documentation: - `opencode` - OpenCode CLI - `qwen` - Qwen Code CLI (Alibaba's AI coding agent) - `gemini` - Gemini CLI (Google's AI coding agent) - - `agent` - @link-assistant/agent (unrestricted OpenCode fork) + - `agent` - @link-assistant/agent (OpenCode fork with native permission modes) - **Multiple Isolation Modes**: - No isolation (direct execution) - Screen sessions (detached terminal sessions) @@ -79,7 +79,7 @@ bun add agent-commander | `opencode` | OpenCode CLI | ✅ | ❌ | ✅ `OPENCODE_PERMISSION` deny rules | `grok`, `gemini`, `sonnet` | | `qwen` | Qwen Code CLI | ✅ (stream-json) | ✅ (stream-json) | ✅ `--approval-mode plan` | `qwen3-coder`, `coder`, `gpt-4o` | | `gemini` | Gemini CLI | ✅ (stream-json) | ❌ | ✅ `--approval-mode plan` | `flash`, `pro`, `lite` | -| `agent` | @link-assistant/agent | ✅ | ✅ | ❌ not enforceable | `nemotron-3-super-free` (default), `grok`, `sonnet`, `haiku` | +| `agent` | @link-assistant/agent | ✅ | ✅ | ✅ `--permission-mode readonly/plan` | `nemotron-3-super-free` (default), `grok`, `sonnet`, `haiku` | ### Claude-specific Features @@ -121,7 +121,8 @@ The [Gemini CLI](https://github.com/google-gemini/gemini-cli) supports additiona The [@link-assistant/agent](https://github.com/link-assistant/agent) supports additional features: - **JSON Input/Output**: Accepts JSON via stdin, outputs JSON event streams (OpenCode-compatible) -- **Unrestricted access**: No sandbox, no permissions system - full autonomous execution +- **Native permission system**: Enforceable `--permission-mode` (`auto` | `plan` | `readonly` | `ask`) plus an OpenCode-compatible `--permission` JSON policy (agent v0.24.0); `--read-only`/`--plan-only` map to it directly +- **Autonomous by default**: Runs with `--permission-mode auto` (full auto, never asks) unless a read-only/planning restriction is requested - **13 built-in tools**: Including websearch, codesearch, batch - all enabled by default - **MCP support**: Model Context Protocol for extending functionality with MCP servers - **OpenCode compatibility**: 100% compatible with OpenCode's JSON event streaming format @@ -135,8 +136,11 @@ Use `--read-only` or `--plan-only` when the selected agent should inspect and pl - `opencode`: `OPENCODE_PERMISSION='{"edit":"deny","bash":"deny","task":"deny"}'` - `qwen`: `--approval-mode plan` - `gemini`: `--approval-mode plan` +- `agent`: `--permission-mode readonly` for `--read-only`, `--permission-mode plan` for `--plan-only` -If a tool cannot enforce the requested restrictions, `start-agent` fails before starting the agent. For example, `--tool agent --read-only` is rejected because @link-assistant/agent has no native permission system. +Most tools treat `--plan-only` as an alias for `--read-only`. The `agent` tool honors its own distinction: `--read-only` selects the hard `readonly` mode (deny every edit and any non read-only shell command, never asks) while `--plan-only` selects `plan` (deny edits, allow read-only shell, ask before anything else). For fine-grained OpenCode-style policies, pass `agent`'s native `--permission ''` through `--tool-arg`. + +If a tool cannot enforce the requested restrictions, `start-agent` fails before starting the agent. ## CLI Usage @@ -160,7 +164,7 @@ start-agent --tool claude --working-directory "/tmp/dir" --prompt "Solve the iss - `--fallback-model ` - Fallback model when default is overloaded (Claude only) - `--verbose` - Enable verbose mode (Claude only) - `--read-only` - Enforce native read-only/planning mode for supported tools -- `--plan-only` - Alias for `--read-only` +- `--plan-only` - Alias for `--read-only` for most tools; the `agent` tool maps it to its softer `--permission-mode plan` - `--resume ` - Resume a previous session by ID - `--session-id ` - Use a specific session ID (Claude only, must be valid UUID) - `--fork-session` - Create new session ID when resuming (Claude only) @@ -552,6 +556,7 @@ Creates an agent controller. - `options.json` (boolean, optional) - Enable JSON output mode - `options.resume` (string, optional) - Resume session ID (tool-specific) - `options.readOnly` (boolean, optional) - Enforce native read-only/planning mode +- `options.planOnly` (boolean, optional) - Enforce native planning mode; the `agent` tool maps it to `--permission-mode plan` while other tools treat it like `readOnly` - `options.isolation` (string, optional) - 'none', 'screen', or 'docker' (default: 'none') - `options.screenName` (string, optional) - Screen session name (required for screen isolation) - `options.containerName` (string, optional) - Container name (required for docker isolation) @@ -560,7 +565,8 @@ Creates an agent controller. - `extraEnv` (object or `KEY=VALUE` / `[key, value]` array, optional) - Environment variables applied to the native tool process - `extraArgs` (string array, optional) - Raw native tool arguments appended after typed agent-commander arguments - `skipDefaultSafetyFlags` (boolean, optional) - Do not add default autonomous safety bypass flags such as Claude/Codex bypass flags or Qwen/Gemini `--yolo` - - `permissionMode` (string, optional) - Explicit Claude permission mode + - `permissionMode` (string, optional) - Explicit permission mode for Claude or `agent` (agent: `auto` | `plan` | `readonly` | `ask`) + - `permission` (string, optional) - OpenCode-compatible `--permission` JSON policy for the `agent` tool - `sandboxMode` (string, optional) - Explicit Codex sandbox mode - `approvalMode` (string, optional) - Explicit Codex approval mode @@ -752,5 +758,5 @@ This is free and unencumbered software released into the public domain. See [LIC ## Related Projects - [hive-mind](https://github.com/link-assistant/hive-mind) - Multi-agent GitHub issue solver -- [@link-assistant/agent](https://github.com/link-assistant/agent) - Unrestricted OpenCode fork for autonomous agents +- [@link-assistant/agent](https://github.com/link-assistant/agent) - OpenCode fork for autonomous agents with a native permission system - [test-anywhere](https://github.com/link-foundation/test-anywhere) - Universal JavaScript testing diff --git a/docs/common-concepts.md b/docs/common-concepts.md index 4404b83..264684a 100644 --- a/docs/common-concepts.md +++ b/docs/common-concepts.md @@ -13,7 +13,7 @@ Both packages support these tool names: | `opencode` | OpenCode CLI | permission deny rules | | `qwen` | Qwen Code CLI | `--approval-mode plan` | | `gemini` | Gemini CLI | `--approval-mode plan` | -| `agent` | @link-assistant/agent | not enforceable | +| `agent` | @link-assistant/agent | `--permission-mode readonly` (`--plan-only` → `plan`) | Unsupported tools can still be executed through the generic command builder, but read-only planning mode is rejected unless the tool has an enforceable native restriction. @@ -46,7 +46,7 @@ Both packages expose raw passthrough controls for the native `claude`, `codex`, Passthrough environment variables are attached to the native tool side of prompt pipelines, so `cat prompt.txt | env KEY=value codex exec ...` applies `KEY` to `codex` without altering prompt-file reads. Raw arguments are appended after typed arguments, allowing callers to override or extend native CLI behavior such as MCP config, reasoning config, permission modes, sandbox modes, approval modes, and custom config paths. -Claude and Codex builders also expose typed `permissionMode` / `permission_mode`, `sandboxMode` / `sandbox_mode`, and `approvalMode` / `approval_mode` fields for callers that build commands directly. +Claude and Codex builders also expose typed `permissionMode` / `permission_mode`, `sandboxMode` / `sandbox_mode`, and `approvalMode` / `approval_mode` fields for callers that build commands directly. The `agent` builder exposes typed `permissionMode` / `permission_mode` (`auto` | `plan` | `readonly` | `ask`) and an OpenCode-compatible `permission` / `permission` JSON policy. `--read-only` maps to `readonly` and `--plan-only` maps to `plan` for `agent`. ## Claude Options diff --git a/js/.changeset/agent-permission-mode.md b/js/.changeset/agent-permission-mode.md new file mode 100644 index 0000000..ff0f83b --- /dev/null +++ b/js/.changeset/agent-permission-mode.md @@ -0,0 +1,5 @@ +--- +'agent-commander': minor +--- + +Map `--read-only` and `--plan-only` for the `agent` tool to its native `--permission-mode` (agent v0.24.0): `--read-only` → `readonly` and `--plan-only` → `plan`. The `agent` tool now supports enforceable read-only/planning mode instead of being rejected, and exposes typed `permissionMode` and `permission` (OpenCode-compatible JSON policy) passthrough options. diff --git a/js/bin/start-agent.mjs b/js/bin/start-agent.mjs index c52749c..0da2317 100755 --- a/js/bin/start-agent.mjs +++ b/js/bin/start-agent.mjs @@ -42,6 +42,7 @@ async function main() { model: options.model, resume: options.resume, readOnly: options.readOnly, + planOnly: options.planOnly, isolation: options.isolation, screenName: options.screenName, containerName: options.containerName, diff --git a/js/src/cli-parser.mjs b/js/src/cli-parser.mjs index 8b28ac5..52e86c9 100644 --- a/js/src/cli-parser.mjs +++ b/js/src/cli-parser.mjs @@ -90,6 +90,7 @@ export function parseStartAgentArgs(args) { verbose: parsed.verbose || false, replayUserMessages: parsed['replay-user-messages'] || false, readOnly: parsed['read-only'] || parsed['plan-only'] || false, + planOnly: parsed['plan-only'] || false, resume: parsed.resume, sessionId: parsed['session-id'], forkSession: parsed['fork-session'] || false, @@ -141,8 +142,8 @@ Options: --model Model to use (e.g., 'sonnet', 'opus', 'haiku') --fallback-model Fallback model when default is overloaded --verbose Enable verbose mode - --read-only Enforce native read-only/planning mode - --plan-only Alias for --read-only + --read-only Enforce native read-only mode (agent: --permission-mode readonly) + --plan-only Enforce native planning mode (agent: --permission-mode plan) --resume Resume a previous session by ID --session-id Use a specific session ID (must be valid UUID) --fork-session Create new session ID when resuming diff --git a/js/src/command-builder.mjs b/js/src/command-builder.mjs index b8fdbc1..36609f6 100644 --- a/js/src/command-builder.mjs +++ b/js/src/command-builder.mjs @@ -20,6 +20,7 @@ import { isToolSupported, getTool } from './tools/index.mjs'; * @param {string} [options.containerName] - Container name (for docker isolation) * @param {boolean} [options.detached] - Run in detached mode * @param {boolean} [options.readOnly] - Enforce native read-only/planning mode + * @param {boolean} [options.planOnly] - Enforce native planning mode (where the tool distinguishes it) * @returns {string} The command string */ export function buildAgentCommand(options) { @@ -37,15 +38,20 @@ export function buildAgentCommand(options) { containerName, detached = false, readOnly = false, + planOnly = false, ...toolOptions } = options; + // A planning request implies a read-only restriction for tools that do not + // distinguish the two modes. + const readOnlyRequested = readOnly || planOnly; + // Build base command using tool-specific builder if available let baseCommand; if (isToolSupported({ toolName: tool })) { const toolConfig = getTool({ toolName: tool }); - if (readOnly && !toolConfig.supportsReadOnly) { + if (readOnlyRequested && !toolConfig.supportsReadOnly) { throw new Error(readOnlyUnsupportedError(tool)); } if (toolConfig.buildCommand) { @@ -58,7 +64,8 @@ export function buildAgentCommand(options) { model, json, resume, - readOnly, + readOnly: readOnlyRequested, + planOnly, ...toolOptions, }); } else { @@ -71,7 +78,7 @@ export function buildAgentCommand(options) { }); } } else { - if (readOnly) { + if (readOnlyRequested) { throw new Error(readOnlyUnsupportedError(tool)); } // Unknown tool, use generic command builder @@ -111,7 +118,7 @@ export function buildAgentCommand(options) { * @returns {string} Error message */ function readOnlyUnsupportedError(tool) { - return `Tool "${tool}" does not support enforceable read-only mode. Choose one of: claude, codex, opencode, gemini, qwen; or run without --read-only.`; + return `Tool "${tool}" does not support enforceable read-only mode. Choose one of: claude, codex, opencode, gemini, qwen, agent; or run without --read-only.`; } /** diff --git a/js/src/index.mjs b/js/src/index.mjs index 142b584..085b43b 100644 --- a/js/src/index.mjs +++ b/js/src/index.mjs @@ -6,7 +6,7 @@ * - claude: Anthropic Claude Code CLI * - codex: OpenAI Codex CLI * - opencode: OpenCode CLI - * - agent: @link-assistant/agent (unrestricted OpenCode fork) + * - agent: @link-assistant/agent (OpenCode fork with native permission modes) * - qwen: Qwen Code CLI * - gemini: Gemini CLI */ @@ -150,6 +150,7 @@ function parseJsonMessages(options) { * @param {boolean} [options.json=false] - Enable JSON output mode * @param {string} [options.resume] - Resume a previous session (tool-specific) * @param {boolean} [options.readOnly=false] - Enforce native read-only/planning mode + * @param {boolean} [options.planOnly=false] - Enforce native planning mode (where the tool distinguishes it) * @param {Object} [options.toolOptions] - Additional tool-specific options * @returns {Object} Agent controller with start, stop, and utility methods */ @@ -167,6 +168,7 @@ export function agent(options) { json = false, resume, readOnly = false, + planOnly = false, toolOptions = {}, } = options; @@ -274,6 +276,7 @@ export function agent(options) { containerName, detached, readOnly, + planOnly, }; // Add tool-specific options if tool is known diff --git a/js/src/tools/agent.mjs b/js/src/tools/agent.mjs index 09b200a..7605730 100644 --- a/js/src/tools/agent.mjs +++ b/js/src/tools/agent.mjs @@ -1,7 +1,10 @@ /** * Agent CLI tool configuration (@link-assistant/agent) * Based on hive-mind's agent.lib.mjs implementation - * Agent is a fork of OpenCode with unrestricted permissions for autonomous execution + * Agent is a fork of OpenCode that ships a native, enforceable permission + * system (agent v0.24.0, PR #272) exposed through `--permission-mode` + * (auto | plan | readonly | ask) and an OpenCode-compatible `--permission` + * JSON policy. */ import { buildCommandHead, escapeArg, normalizeExtraArgs } from './shell.mjs'; @@ -67,6 +70,10 @@ export function mapModelToId(options) { * @param {string} [options.model] - Model to use * @param {boolean} [options.compactJson] - Use compact JSON output * @param {boolean} [options.useExistingClaudeOAuth] - Use existing Claude OAuth credentials + * @param {boolean} [options.readOnly] - Enforce hard read-only mode (`--permission-mode readonly`) + * @param {boolean} [options.planOnly] - Enforce planning mode (`--permission-mode plan`) + * @param {string} [options.permissionMode] - Explicit agent permission mode (auto | plan | readonly | ask) + * @param {string} [options.permission] - OpenCode-compatible `--permission` JSON policy * @param {string[]} [options.extraArgs] - Extra raw CLI args appended after typed args * @returns {string[]} Array of CLI arguments */ @@ -75,11 +82,28 @@ export function buildArgs(options) { model, compactJson = false, useExistingClaudeOAuth = false, + readOnly = false, + planOnly = false, + permissionMode, + permission, extraArgs = [], } = options; const args = []; + // Native, enforceable permission system (agent v0.24.0, PR #272). + // --plan-only maps to `plan`, --read-only maps to the harder `readonly`, + // matching agent's own distinction between the two modes. + const resolvedPermissionMode = + permissionMode || (planOnly ? 'plan' : readOnly ? 'readonly' : undefined); + if (resolvedPermissionMode) { + args.push('--permission-mode', resolvedPermissionMode); + } + + if (permission) { + args.push('--permission', permission); + } + if (model) { const mappedModel = mapModelToId({ model }); args.push('--model', mappedModel); @@ -109,6 +133,10 @@ export function buildArgs(options) { * @param {string} [options.model] - Model to use * @param {boolean} [options.compactJson] - Use compact JSON output * @param {boolean} [options.useExistingClaudeOAuth] - Use existing Claude OAuth + * @param {boolean} [options.readOnly] - Enforce hard read-only mode (`--permission-mode readonly`) + * @param {boolean} [options.planOnly] - Enforce planning mode (`--permission-mode plan`) + * @param {string} [options.permissionMode] - Explicit agent permission mode (auto | plan | readonly | ask) + * @param {string} [options.permission] - OpenCode-compatible `--permission` JSON policy * @param {string} [options.executable='agent'] - Executable path/name * @param {Object|Array} [options.extraEnv] - Environment variables for the tool * @param {string[]} [options.extraArgs] - Extra raw CLI args appended after typed args @@ -281,7 +309,7 @@ export const agentTool = { supportsJsonInput: true, // Agent supports full JSON streaming input supportsSystemPrompt: false, // System prompt is combined with user prompt supportsResume: false, // Agent doesn't have explicit resume like Claude - supportsReadOnly: false, // No native enforceable read-only mode + supportsReadOnly: true, // Native --permission-mode readonly/plan (agent v0.24.0, PR #272) defaultModel: 'nemotron-3-super-free', // hive-mind issue #1563, agent PR #243 modelMap, mapModelToId, diff --git a/js/test/command-builder.test.mjs b/js/test/command-builder.test.mjs index 13b69c0..a8620c2 100644 --- a/js/test/command-builder.test.mjs +++ b/js/test/command-builder.test.mjs @@ -368,10 +368,35 @@ test('buildAgentCommand - read-only still works with screen isolation', () => { assert.ok(command.includes('plan')); }); -test('buildAgentCommand - read-only rejects unsupported agent tool', () => { +test('buildAgentCommand - agent read-only uses readonly permission mode', () => { + const command = buildAgentCommand({ + tool: 'agent', + workingDirectory: '/tmp/test', + readOnly: true, + isolation: 'none', + }); + + assert.ok(command.includes('--permission-mode')); + assert.ok(command.includes('readonly')); +}); + +test('buildAgentCommand - agent plan-only uses plan permission mode', () => { + const command = buildAgentCommand({ + tool: 'agent', + workingDirectory: '/tmp/test', + planOnly: true, + isolation: 'none', + }); + + assert.ok(command.includes('--permission-mode')); + assert.ok(command.includes('plan')); + assert.ok(!command.includes('readonly')); +}); + +test('buildAgentCommand - read-only rejects unsupported tool', () => { assert.throws(() => { buildAgentCommand({ - tool: 'agent', + tool: 'unknown-tool', workingDirectory: '/tmp/test', readOnly: true, isolation: 'none', diff --git a/js/test/tools.test.mjs b/js/test/tools.test.mjs index d9e3ef5..f694a63 100644 --- a/js/test/tools.test.mjs +++ b/js/test/tools.test.mjs @@ -284,6 +284,44 @@ test('agentTool - buildArgs with model', () => { assert.ok(args.includes('opencode/grok-code')); }); +test('agentTool - supportsReadOnly is true', () => { + assert.strictEqual(agentTool.supportsReadOnly, true); +}); + +test('agentTool - buildArgs read-only uses readonly permission mode', () => { + const args = agentTool.buildArgs({ readOnly: true }); + const idx = args.indexOf('--permission-mode'); + assert.ok(idx !== -1); + assert.strictEqual(args[idx + 1], 'readonly'); +}); + +test('agentTool - buildArgs plan-only uses plan permission mode', () => { + const args = agentTool.buildArgs({ planOnly: true }); + const idx = args.indexOf('--permission-mode'); + assert.ok(idx !== -1); + assert.strictEqual(args[idx + 1], 'plan'); +}); + +test('agentTool - buildArgs explicit permissionMode overrides read-only', () => { + const args = agentTool.buildArgs({ readOnly: true, permissionMode: 'ask' }); + const idx = args.indexOf('--permission-mode'); + assert.ok(idx !== -1); + assert.strictEqual(args[idx + 1], 'ask'); +}); + +test('agentTool - buildArgs passes through permission JSON policy', () => { + const policy = '{"edit":"deny"}'; + const args = agentTool.buildArgs({ permission: policy }); + const idx = args.indexOf('--permission'); + assert.ok(idx !== -1); + assert.strictEqual(args[idx + 1], policy); +}); + +test('agentTool - buildArgs without read-only omits permission mode', () => { + const args = agentTool.buildArgs({ model: 'grok' }); + assert.ok(!args.includes('--permission-mode')); +}); + test('agentTool - extractUsage from step_finish events', () => { const output = `{"type":"step_finish","part":{"tokens":{"input":100,"output":50},"cost":0}} {"type":"step_finish","part":{"tokens":{"input":200,"output":75},"cost":0}}`; diff --git a/rust/changelog.d/20260617_120000_agent_permission_mode.md b/rust/changelog.d/20260617_120000_agent_permission_mode.md new file mode 100644 index 0000000..a44a99c --- /dev/null +++ b/rust/changelog.d/20260617_120000_agent_permission_mode.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- Map `--read-only` and `--plan-only` for the `agent` tool to its native `--permission-mode` (agent v0.24.0): `--read-only` → `readonly` and `--plan-only` → `plan`. The `agent` tool now supports enforceable read-only/planning mode instead of being rejected, and exposes typed `permission_mode` and `permission` (OpenCode-compatible JSON policy) passthrough options. diff --git a/rust/src/bin/start_agent.rs b/rust/src/bin/start_agent.rs index 34ac1df..9602179 100644 --- a/rust/src/bin/start_agent.rs +++ b/rust/src/bin/start_agent.rs @@ -64,6 +64,7 @@ async fn main() { session_id: options.session_id, fork_session: options.fork_session, read_only: options.read_only, + plan_only: options.plan_only, executable: options.tool_executable, extra_args: options.tool_args, extra_env, diff --git a/rust/src/cli_parser.rs b/rust/src/cli_parser.rs index da6d25b..58b5e61 100644 --- a/rust/src/cli_parser.rs +++ b/rust/src/cli_parser.rs @@ -88,6 +88,7 @@ pub struct StartAgentOptions { pub verbose: bool, pub replay_user_messages: bool, pub read_only: bool, + pub plan_only: bool, pub resume: Option, pub session_id: Option, pub fork_session: bool, @@ -142,6 +143,7 @@ pub fn parse_start_agent_args(args: &[String]) -> StartAgentOptions { verbose: parsed.get_bool("verbose"), replay_user_messages: parsed.get_bool("replay-user-messages"), read_only: parsed.get_bool("read-only") || parsed.get_bool("plan-only"), + plan_only: parsed.get_bool("plan-only"), resume: parsed.get("resume").cloned(), session_id: parsed.get("session-id").cloned(), fork_session: parsed.get_bool("fork-session"), @@ -194,8 +196,8 @@ Options: --model Model to use (e.g., 'sonnet', 'opus', 'haiku') --fallback-model Fallback model when default is overloaded --verbose Enable verbose mode - --read-only Enforce native read-only/planning mode - --plan-only Alias for --read-only + --read-only Enforce native read-only mode (agent: --permission-mode readonly) + --plan-only Enforce native planning mode (agent: --permission-mode plan) --resume Resume a previous session by ID --session-id Use a specific session ID (must be valid UUID) --fork-session Create new session ID when resuming diff --git a/rust/src/command_builder.rs b/rust/src/command_builder.rs index 0f24b73..acf9095 100644 --- a/rust/src/command_builder.rs +++ b/rust/src/command_builder.rs @@ -28,6 +28,7 @@ pub struct AgentCommandOptions { pub session_id: Option, pub fork_session: bool, pub read_only: bool, + pub plan_only: bool, pub executable: Option, pub extra_args: Vec, pub extra_env: Vec<(String, String)>, @@ -40,13 +41,16 @@ pub struct AgentCommandOptions { /// Check whether a tool has an enforceable native read-only/planning mode. pub fn supports_read_only(tool: &str) -> bool { - matches!(tool, "claude" | "codex" | "opencode" | "gemini" | "qwen") + matches!( + tool, + "claude" | "codex" | "opencode" | "gemini" | "qwen" | "agent" + ) } /// Build the standard error for tools without enforceable read-only mode. pub fn read_only_unsupported_error(tool: &str) -> String { format!( - "Tool \"{}\" does not support enforceable read-only mode. Choose one of: claude, codex, opencode, gemini, qwen; or run without --read-only.", + "Tool \"{}\" does not support enforceable read-only mode. Choose one of: claude, codex, opencode, gemini, qwen, agent; or run without --read-only.", tool ) } @@ -153,8 +157,12 @@ fn build_docker_command( /// # Returns /// The command string pub fn build_agent_command(options: &AgentCommandOptions) -> String { + // A planning request implies a read-only restriction for tools that do not + // distinguish the two modes. + let read_only_requested = options.read_only || options.plan_only; + assert!( - !(options.read_only && !supports_read_only(&options.tool)), + !(read_only_requested && !supports_read_only(&options.tool)), "{}", read_only_unsupported_error(&options.tool) ); @@ -177,7 +185,7 @@ pub fn build_agent_command(options: &AgentCommandOptions) -> String { session_id: options.session_id.clone(), fork_session: options.fork_session, print: false, - read_only: options.read_only, + read_only: read_only_requested, executable: options.executable.clone(), extra_env: options.extra_env.clone(), extra_args: options.extra_args.clone(), @@ -191,7 +199,7 @@ pub fn build_agent_command(options: &AgentCommandOptions) -> String { model: options.model.clone(), json: options.json, resume: options.resume.clone(), - read_only: options.read_only, + read_only: read_only_requested, executable: options.executable.clone(), extra_env: options.extra_env.clone(), extra_args: options.extra_args.clone(), @@ -206,7 +214,7 @@ pub fn build_agent_command(options: &AgentCommandOptions) -> String { model: options.model.clone(), json: options.json, resume: options.resume.clone(), - read_only: options.read_only, + read_only: read_only_requested, executable: options.executable.clone(), extra_env: options.extra_env.clone(), extra_args: options.extra_args.clone(), @@ -218,6 +226,10 @@ pub fn build_agent_command(options: &AgentCommandOptions) -> String { model: options.model.clone(), compact_json: false, use_existing_claude_oauth: false, + read_only: read_only_requested, + plan_only: options.plan_only, + permission_mode: None, + permission: None, executable: options.executable.clone(), extra_env: options.extra_env.clone(), extra_args: options.extra_args.clone(), @@ -229,7 +241,7 @@ pub fn build_agent_command(options: &AgentCommandOptions) -> String { system_prompt: options.system_prompt.clone(), model: options.model.clone(), json: options.json, - read_only: options.read_only, + read_only: read_only_requested, executable: options.executable.clone(), extra_env: options.extra_env.clone(), extra_args: options.extra_args.clone(), @@ -246,7 +258,7 @@ pub fn build_agent_command(options: &AgentCommandOptions) -> String { model: options.model.clone(), json: options.json, resume: options.resume.clone(), - read_only: options.read_only, + read_only: read_only_requested, executable: options.executable.clone(), extra_env: options.extra_env.clone(), extra_args: options.extra_args.clone(), @@ -795,11 +807,45 @@ mod tests { } #[test] - #[should_panic(expected = "does not support enforceable read-only mode")] - fn test_build_agent_command_read_only_rejects_agent_tool() { + fn test_build_agent_command_agent_read_only_uses_readonly_mode() { + let options = AgentCommandOptions { + tool: "agent".to_string(), + working_directory: "/tmp/test".to_string(), + prompt: Some("Inspect only".to_string()), + read_only: true, + isolation: "none".to_string(), + ..Default::default() + }; + + let command = build_agent_command(&options); + assert!(command.contains("--permission-mode")); + assert!(command.contains("readonly")); + assert!(!command.contains("plan")); + } + + #[test] + fn test_build_agent_command_agent_plan_only_uses_plan_mode() { let options = AgentCommandOptions { tool: "agent".to_string(), working_directory: "/tmp/test".to_string(), + prompt: Some("Plan only".to_string()), + plan_only: true, + isolation: "none".to_string(), + ..Default::default() + }; + + let command = build_agent_command(&options); + assert!(command.contains("--permission-mode")); + assert!(command.contains("plan")); + assert!(!command.contains("readonly")); + } + + #[test] + #[should_panic(expected = "does not support enforceable read-only mode")] + fn test_build_agent_command_read_only_rejects_unknown_tool() { + let options = AgentCommandOptions { + tool: "unknown-tool".to_string(), + working_directory: "/tmp/test".to_string(), read_only: true, isolation: "none".to_string(), ..Default::default() diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 5d46237..3c6b58c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -5,7 +5,7 @@ //! - claude: Anthropic Claude Code CLI //! - codex: OpenAI Codex CLI //! - opencode: OpenCode CLI -//! - agent: @link-assistant/agent (unrestricted OpenCode fork) +//! - agent: @link-assistant/agent (OpenCode fork with native permission modes) //! - qwen: Qwen Code CLI //! - gemini: Gemini CLI @@ -90,6 +90,8 @@ pub struct AgentOptions { pub fork_session: bool, /// Enforce native read-only/planning mode pub read_only: bool, + /// Enforce native planning mode (where the tool distinguishes it) + pub plan_only: bool, /// Override the tool executable path/name pub executable: Option, /// Extra raw arguments appended after typed tool arguments @@ -243,7 +245,7 @@ impl Agent { if options.isolation == "docker" && options.container_name.is_none() { return Err("container_name is required for docker isolation".to_string()); } - if options.read_only && !supports_read_only(&options.tool) { + if (options.read_only || options.plan_only) && !supports_read_only(&options.tool) { return Err(read_only_unsupported_error(&options.tool)); } @@ -352,6 +354,7 @@ impl Agent { session_id: self.options.session_id.clone(), fork_session: self.options.fork_session, read_only: self.options.read_only, + plan_only: self.options.plan_only, executable: self.options.executable.clone(), extra_args: self.options.extra_args.clone(), extra_env: self.options.extra_env.clone(), diff --git a/rust/src/tools/agent.rs b/rust/src/tools/agent.rs index 7d2af48..a53a043 100644 --- a/rust/src/tools/agent.rs +++ b/rust/src/tools/agent.rs @@ -1,6 +1,10 @@ -//! Agent CLI tool configuration (@link-assistant/agent) -//! Based on hive-mind's agent.lib.mjs implementation -//! Agent is a fork of OpenCode with unrestricted permissions for autonomous execution +//! Agent CLI tool configuration (@link-assistant/agent). +//! +//! Based on hive-mind's agent.lib.mjs implementation. +//! Agent is a fork of OpenCode that ships a native, enforceable permission +//! system (agent v0.24.0, PR #272) exposed through `--permission-mode` +//! (auto | plan | readonly | ask) and an OpenCode-compatible `--permission` +//! JSON policy. use crate::streaming::parse_ndjson; use crate::tools::shell::{build_command_head, escape_arg, escape_single_quotes}; @@ -63,6 +67,14 @@ pub struct AgentBuildOptions { pub model: Option, pub compact_json: bool, pub use_existing_claude_oauth: bool, + /// Enforce hard read-only mode (`--permission-mode readonly`) + pub read_only: bool, + /// Enforce planning mode (`--permission-mode plan`) + pub plan_only: bool, + /// Explicit agent permission mode (auto | plan | readonly | ask) + pub permission_mode: Option, + /// OpenCode-compatible `--permission` JSON policy + pub permission: Option, pub executable: Option, pub extra_env: Vec<(String, String)>, pub extra_args: Vec, @@ -78,6 +90,28 @@ pub struct AgentBuildOptions { pub fn build_args(options: &AgentBuildOptions) -> Vec { let mut args = Vec::new(); + // Native, enforceable permission system (agent v0.24.0, PR #272). + // --plan-only maps to `plan`, --read-only maps to the harder `readonly`, + // matching agent's own distinction between the two modes. + let resolved_permission_mode = options.permission_mode.clone().or_else(|| { + if options.plan_only { + Some("plan".to_string()) + } else if options.read_only { + Some("readonly".to_string()) + } else { + None + } + }); + if let Some(mode) = resolved_permission_mode { + args.push("--permission-mode".to_string()); + args.push(mode); + } + + if let Some(ref permission) = options.permission { + args.push("--permission".to_string()); + args.push(permission.clone()); + } + if let Some(ref model) = options.model { let mapped_model = map_model_to_id(model); args.push("--model".to_string()); @@ -288,7 +322,7 @@ impl Default for AgentTool { supports_json_input: true, // Agent supports full JSON streaming input supports_system_prompt: false, // System prompt is combined with user prompt supports_resume: false, // Agent doesn't have explicit resume like Claude - supports_read_only: false, // No native enforceable read-only mode + supports_read_only: true, // Native --permission-mode readonly/plan (agent v0.24.0, PR #272) default_model: "nemotron-3-super-free", // hive-mind issue #1563, agent PR #243 } }