@zhijiewang/openharness 2.35.0 → 2.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -563,6 +563,19 @@ Agent({ subagent_type: 'architect', prompt: 'Plan a migration from option A to o
563
563
  Agent({ subagent_type: 'editor', prompt: '<paste plan>' })
564
564
  ```
565
565
 
566
+ ### Sub-agent permission isolation
567
+
568
+ Each `Agent` call accepts a `permission_mode` override that **narrows** the parent's permission mode (never loosens it). Useful when running in `trust` and you want a subagent's review/audit pass to stay strictly read-only:
569
+
570
+ ```
571
+ Agent({ subagent_type: 'code-reviewer', prompt: '...', permission_mode: 'plan' })
572
+ Agent({ subagent_type: 'security-auditor', prompt: '...', permission_mode: 'deny' })
573
+ ```
574
+
575
+ If a less-restrictive mode is requested (e.g. parent is `ask`, subagent requests `trust`), the harness silently clamps to the parent — a model can never use a sub-agent to escape user-approval gates.
576
+
577
+ **Read-only roles default to `plan` automatically.** `code-reviewer`, `evaluator`, `security-auditor`, `architect`, and `planner` ship with `permissionMode: 'plan'` — spawn them under any parent and they're statically read-only, no `permission_mode` override needed. Markdown-defined agents in `.oh/agents/*.md` can set their own default with `permissionMode: plan` (or `permission-mode: plan`) frontmatter.
578
+
566
579
  ## Headless Mode
567
580
 
568
581
  Run a single prompt without interactive UI — perfect for CI/CD and scripting:
package/README.zh-CN.md CHANGED
@@ -563,6 +563,19 @@ Agent({ subagent_type: 'architect', prompt: 'Plan a migration from option A to o
563
563
  Agent({ subagent_type: 'editor', prompt: '<paste plan>' })
564
564
  ```
565
565
 
566
+ ### 子代理的权限隔离
567
+
568
+ `Agent` 调用支持 `permission_mode` 参数,**只能收紧不能放宽**父级的权限模式。当父代理跑在 `trust` 但你希望某个评审/审计子代理保持只读时尤其有用:
569
+
570
+ ```
571
+ Agent({ subagent_type: 'code-reviewer', prompt: '...', permission_mode: 'plan' })
572
+ Agent({ subagent_type: 'security-auditor', prompt: '...', permission_mode: 'deny' })
573
+ ```
574
+
575
+ 如果请求的模式比父级更宽松(比如父级 `ask`、子代理请求 `trust`),harness 会静默回退到父级的模式 —— 模型永远不能借助子代理绕过用户的批准门。
576
+
577
+ **只读角色自动默认 `plan` 模式。** `code-reviewer`、`evaluator`、`security-auditor`、`architect`、`planner` 内置 `permissionMode: 'plan'` —— 在任何父级权限下启动它们都是静态只读,无需在调用处再传 `permission_mode`。`.oh/agents/*.md` 里自定义的 markdown agent 也可以在 frontmatter 写 `permissionMode: plan`(或 `permission-mode: plan`)来设默认。
578
+
566
579
  ## 无头模式
567
580
 
568
581
  跑一次提示词,不走交互 UI —— 适合 CI/CD 和脚本化:
@@ -21,6 +21,20 @@ export type AgentRole = {
21
21
  model?: string;
22
22
  /** Isolation mode for sub-agent execution. Default: inherits parent. */
23
23
  isolation?: "none" | "worktree";
24
+ /**
25
+ * Default permission mode for this role. When the AgentTool caller doesn't
26
+ * pass `permission_mode`, this value is used. Always passes through the
27
+ * narrowing-only safety clamp (v2.36): a role marked `plan` can't loosen
28
+ * the parent's stricter mode, only set the floor when the parent is looser.
29
+ *
30
+ * Use cases:
31
+ * - `plan` for read-only roles (code-reviewer, evaluator, security-auditor,
32
+ * architect, planner) — locks them as read-only even when the parent
33
+ * is in `trust`, so the review pass can't accidentally write anything.
34
+ * - Leave undefined for roles that mutate (editor, migrator, refactorer,
35
+ * test-writer, debugger) — they need the parent's mode to do their job.
36
+ */
37
+ permissionMode?: import("../types/permissions.js").PermissionMode;
24
38
  /** Per-agent MCP servers injected only when this agent runs (parsed via raw passthrough; dispatcher wiring is a future task). */
25
39
  mcpServers?: Record<string, unknown>;
26
40
  /** Per-agent hooks (parsed via raw passthrough; dispatcher wiring is a future task). */
@@ -22,6 +22,7 @@ const roles = [
22
22
 
23
23
  Be specific: cite file paths, line numbers, and code snippets. Prioritize issues by severity (critical > major > minor). Don't mention things that look fine — focus on problems.`,
24
24
  suggestedTools: ["Read", "Glob", "Grep", "LS"],
25
+ permissionMode: "plan",
25
26
  },
26
27
  {
27
28
  id: "test-writer",
@@ -99,6 +100,7 @@ Do NOT add new features or change behavior. The refactored code must be function
99
100
 
100
101
  Report findings with severity (Critical/High/Medium/Low), affected file:line, and recommended fix.`,
101
102
  suggestedTools: ["Read", "Glob", "Grep", "Bash"],
103
+ permissionMode: "plan",
102
104
  },
103
105
  {
104
106
  id: "evaluator",
@@ -113,6 +115,7 @@ Report findings with severity (Critical/High/Medium/Low), affected file:line, an
113
115
 
114
116
  You CANNOT modify files. Only read, search, and run test/lint commands to evaluate.`,
115
117
  suggestedTools: ["Read", "Glob", "Grep", "LS", "Bash", "Diagnostics"],
118
+ permissionMode: "plan",
116
119
  },
117
120
  {
118
121
  id: "planner",
@@ -127,6 +130,7 @@ You CANNOT modify files. Only read, search, and run test/lint commands to evalua
127
130
 
128
131
  Do NOT implement anything. Your output is a plan document, not code. Read widely before planning.`,
129
132
  suggestedTools: ["Read", "Glob", "Grep", "LS", "Bash"],
133
+ permissionMode: "plan",
130
134
  },
131
135
  {
132
136
  id: "architect",
@@ -152,6 +156,7 @@ When you've finished planning, output a structured "Plan" the editor can apply m
152
156
 
153
157
  Keep each step small enough that an editor (a cheaper model) can apply it without re-deriving your reasoning. Group related edits together; surface dependencies between steps.`,
154
158
  suggestedTools: ["Read", "Glob", "Grep", "LS"],
159
+ permissionMode: "plan",
155
160
  },
156
161
  {
157
162
  id: "editor",
@@ -238,6 +243,7 @@ function parseAgentMarkdown(raw, filePath) {
238
243
  const disallowedMatch = fm.match(/^disallowedTools:\s*(.+)$/m) ?? fm.match(/^disallowed-tools:\s*(.+)$/m);
239
244
  const modelMatch = fm.match(/^model:\s*(.+)$/m);
240
245
  const isolationMatch = fm.match(/^isolation:\s*(.+)$/m);
246
+ const permModeMatch = fm.match(/^permissionMode:\s*(.+)$/m) ?? fm.match(/^permission-mode:\s*(.+)$/m);
241
247
  const fmEnd = raw.indexOf("---", raw.indexOf("---") + 3);
242
248
  const content = fmEnd > 0 ? raw.slice(fmEnd + 3).trim() : "";
243
249
  const id = basename(filePath, ".md")
@@ -245,6 +251,14 @@ function parseAgentMarkdown(raw, filePath) {
245
251
  .replace(/[^a-z0-9]+/g, "-");
246
252
  const isolation = isolationMatch?.[1]?.trim().replace(/^["']|["']$/g, "");
247
253
  const validIsolation = isolation === "worktree" || isolation === "none" ? isolation : undefined;
254
+ // permissionMode: only honor known values; silently drop typos. Same
255
+ // pattern as `isolation` above — we'd rather a misspelled mode produce
256
+ // "no override" than a runtime crash.
257
+ const permModeRaw = permModeMatch?.[1]?.trim().replace(/^["']|["']$/g, "");
258
+ const validModes = ["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"];
259
+ const validPermissionMode = validModes.includes(permModeRaw)
260
+ ? permModeRaw
261
+ : undefined;
248
262
  // Parse optional inline-JSON fields (mcpServers, hooks). These are block fields in
249
263
  // Anthropic's YAML but we support single-line JSON for lightweight frontmatter use.
250
264
  const mcpServersMatch = fm.match(/^mcpServers:\s*(\{[\s\S]*?\})\s*$/m);
@@ -258,6 +272,7 @@ function parseAgentMarkdown(raw, filePath) {
258
272
  disallowedTools: disallowedMatch ? parseAgentList(disallowedMatch[1]) : undefined,
259
273
  model: modelMatch?.[1]?.trim().replace(/^["']|["']$/g, ""),
260
274
  isolation: validIsolation,
275
+ permissionMode: validPermissionMode,
261
276
  mcpServers: mcpServersMatch ? tryParseJson(mcpServersMatch[1]) : undefined,
262
277
  hooks: hooksMatch ? tryParseJson(hooksMatch[1]) : undefined,
263
278
  };
@@ -21,6 +21,7 @@ declare const inputSchema: z.ZodObject<{
21
21
  model: z.ZodOptional<z.ZodString>;
22
22
  subagent_type: z.ZodOptional<z.ZodString>;
23
23
  allowed_tools: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
24
+ permission_mode: z.ZodOptional<z.ZodEnum<["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"]>>;
24
25
  }, "strip", z.ZodTypeAny, {
25
26
  prompt: string;
26
27
  model?: string | undefined;
@@ -30,6 +31,7 @@ declare const inputSchema: z.ZodObject<{
30
31
  run_in_background?: boolean | undefined;
31
32
  subagent_type?: string | undefined;
32
33
  allowed_tools?: string[] | undefined;
34
+ permission_mode?: "ask" | "deny" | "trust" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | undefined;
33
35
  }, {
34
36
  prompt: string;
35
37
  model?: string | undefined;
@@ -39,6 +41,7 @@ declare const inputSchema: z.ZodObject<{
39
41
  run_in_background?: boolean | undefined;
40
42
  subagent_type?: string | undefined;
41
43
  allowed_tools?: string[] | undefined;
44
+ permission_mode?: "ask" | "deny" | "trust" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | undefined;
42
45
  }>;
43
46
  export declare const AgentTool: Tool<typeof inputSchema>;
44
47
  export {};
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { createWorktree, hasWorktreeChanges, isGitRepo, removeWorktree } from "../../git/index.js";
3
3
  import { emitHook } from "../../harness/hooks.js";
4
4
  import { getMessageBus } from "../../services/agent-messaging.js";
5
+ import { clampSubagentPermissionMode } from "../../types/permissions.js";
5
6
  /**
6
7
  * Forward a single inner-query event to the outer stream via `context.emitChildEvent`,
7
8
  * stamping it with `parentCallId = context.callId`.
@@ -36,6 +37,10 @@ const inputSchema = z.object({
36
37
  model: z.string().optional(),
37
38
  subagent_type: z.string().optional(),
38
39
  allowed_tools: z.array(z.string()).optional(),
40
+ permission_mode: z
41
+ .enum(["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"])
42
+ .optional()
43
+ .describe("Restrict the sub-agent's permission mode. Narrowing-only: the harness clamps to the parent's mode if a less-restrictive value is requested. Useful for spawning read-only review/audit agents while the parent runs in 'trust'."),
39
44
  });
40
45
  export const AgentTool = {
41
46
  name: "Agent",
@@ -101,11 +106,24 @@ export const AgentTool = {
101
106
  }
102
107
  // Model override for sub-agent
103
108
  const agentModel = input.model ?? context.model;
109
+ // Permission mode override — narrowing-only. Subagent can be same-or-stricter
110
+ // than parent; a less-restrictive request silently clamps to the parent so a
111
+ // model in `ask` can't spawn a `trust`-mode subagent to bypass user approval.
112
+ //
113
+ // Resolution order, most-specific-wins:
114
+ // 1. input.permission_mode (the call site's explicit choice)
115
+ // 2. role.permissionMode (the role's documented default — e.g.
116
+ // code-reviewer / security-auditor / planner default to "plan")
117
+ // 3. parent's mode (no narrowing — current default)
118
+ // The clamp applies in all cases so #1 and #2 can only narrow, not loosen.
119
+ const parentMode = context.permissionMode ?? "trust";
120
+ const requestedMode = input.permission_mode ?? role?.permissionMode;
121
+ const subagentMode = clampSubagentPermissionMode(parentMode, requestedMode);
104
122
  const config = {
105
123
  provider: context.provider,
106
124
  tools: agentTools,
107
125
  systemPrompt,
108
- permissionMode: context.permissionMode ?? "trust",
126
+ permissionMode: subagentMode,
109
127
  model: agentModel,
110
128
  maxTurns: 20,
111
129
  abortSignal: context.abortSignal,
@@ -225,7 +243,8 @@ export const AgentTool = {
225
243
  - run_in_background (boolean, optional): Run the agent in the background. Returns immediately; you will be notified when it completes.
226
244
  - model (string, optional): Override the model for this sub-agent (e.g., use a faster model for exploration).
227
245
  - subagent_type (string, optional): Specialize the agent behavior. Types: "Explore" (read-only codebase search), "Plan" (design implementation plans), "code-reviewer", "test-writer", "debugger", "refactorer", "security-auditor", "evaluator" (read-only evaluation), "planner" (implementation plans), "architect" (system design), "migrator" (codebase migrations).
228
- - allowed_tools (string[], optional): Restrict the sub-agent to only these tools by name. If omitted and a role has suggested tools, those are used.`;
246
+ - allowed_tools (string[], optional): Restrict the sub-agent to only these tools by name. If omitted and a role has suggested tools, those are used.
247
+ - permission_mode (string, optional): Override the sub-agent's permission mode (one of: ask, trust, deny, acceptEdits, plan, auto, bypassPermissions). Narrowing-only — a less-restrictive value silently clamps to the parent's mode. Use to spawn a read-only audit/review sub-agent in "plan" or "deny" while the parent runs in "trust".`;
229
248
  },
230
249
  };
231
250
  //# sourceMappingURL=index.js.map
@@ -4,6 +4,23 @@
4
4
  import type { ToolPermissionRule } from "../harness/config.js";
5
5
  export type PermissionMode = "ask" | "trust" | "deny" | "acceptEdits" | "plan" | "auto" | "bypassPermissions";
6
6
  export type RiskLevel = "low" | "medium" | "high";
7
+ /**
8
+ * Resolve a subagent's effective permission mode given the parent's mode and
9
+ * an optionally-requested override. The contract: a subagent may be the same
10
+ * strictness as its parent or stricter, never looser.
11
+ *
12
+ * Why this asymmetry: the AgentTool input schema lets the model pick a
13
+ * subagent's mode. Allowing a less-restrictive choice would mean a model in
14
+ * `ask` mode could spawn a `trust`-mode subagent and quietly bypass user
15
+ * approval on its own tool calls — exactly the kind of escalation the
16
+ * permission system exists to prevent. The same pattern as `allowed_tools`,
17
+ * which is also narrowing-only.
18
+ *
19
+ * Returns the parent's mode if the requested override is undefined or would
20
+ * loosen the gate. Returns the requested override only when it's the same or
21
+ * stricter than the parent.
22
+ */
23
+ export declare function clampSubagentPermissionMode(parentMode: PermissionMode, requestedMode: PermissionMode | undefined): PermissionMode;
7
24
  export type PermissionResult = {
8
25
  readonly allowed: boolean;
9
26
  readonly reason: string;
@@ -2,6 +2,56 @@
2
2
  * Permission types — tool permission context and risk-based gating.
3
3
  */
4
4
  import { analyzeBashCommand, isReadOnlyBashCommand, splitCommands, stripProcessWrappers, } from "../utils/bash-safety.js";
5
+ /**
6
+ * Strictness rank for permission modes. Lower = more permissive. Used by
7
+ * `clampSubagentPermissionMode` to enforce that a subagent can never run
8
+ * less restrictively than its parent — only the same or stricter.
9
+ *
10
+ * The ordering is deliberate, not lexical:
11
+ * bypassPermissions (0) — approves everything unconditionally
12
+ * trust (1) — approves everything user-trusted
13
+ * auto (2) — approves except dangerous bash
14
+ * acceptEdits (3) — auto-approves edit-safe tool subset
15
+ * plan (4) — read-only allowed; writes blocked
16
+ * ask (5) — every non-trivial call prompts the user
17
+ * deny (6) — denies everything
18
+ *
19
+ * Why a rank instead of a Set: most-restrictive comparisons are easier when
20
+ * the dimension is total-ordered, and adding a new mode means adding one row
21
+ * to this table rather than re-deriving the partial order across call sites.
22
+ */
23
+ const PERMISSION_STRICTNESS_RANK = {
24
+ bypassPermissions: 0,
25
+ trust: 1,
26
+ auto: 2,
27
+ acceptEdits: 3,
28
+ plan: 4,
29
+ ask: 5,
30
+ deny: 6,
31
+ };
32
+ /**
33
+ * Resolve a subagent's effective permission mode given the parent's mode and
34
+ * an optionally-requested override. The contract: a subagent may be the same
35
+ * strictness as its parent or stricter, never looser.
36
+ *
37
+ * Why this asymmetry: the AgentTool input schema lets the model pick a
38
+ * subagent's mode. Allowing a less-restrictive choice would mean a model in
39
+ * `ask` mode could spawn a `trust`-mode subagent and quietly bypass user
40
+ * approval on its own tool calls — exactly the kind of escalation the
41
+ * permission system exists to prevent. The same pattern as `allowed_tools`,
42
+ * which is also narrowing-only.
43
+ *
44
+ * Returns the parent's mode if the requested override is undefined or would
45
+ * loosen the gate. Returns the requested override only when it's the same or
46
+ * stricter than the parent.
47
+ */
48
+ export function clampSubagentPermissionMode(parentMode, requestedMode) {
49
+ if (!requestedMode)
50
+ return parentMode;
51
+ return PERMISSION_STRICTNESS_RANK[requestedMode] >= PERMISSION_STRICTNESS_RANK[parentMode]
52
+ ? requestedMode
53
+ : parentMode;
54
+ }
5
55
  /** Tools auto-approved in acceptEdits mode */
6
56
  const EDIT_SAFE_TOOLS = new Set([
7
57
  "FileRead",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.35.0",
3
+ "version": "2.37.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {