one-code-extension 0.1.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 (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Claude Code permission-rule matching (pure).
3
+ *
4
+ * Rules use Claude Code's settings.json syntax: a bare tool name ("Bash") or
5
+ * "Tool(pattern)" ("Bash(npm run test:*)", "Edit(docs/**)"). Claude Code
6
+ * PascalCase tool names are mapped to this package's pi tool names so users'
7
+ * existing ~/.claude/settings.json files work unchanged.
8
+ */
9
+
10
+ import { homedir } from "node:os";
11
+ import { isAbsolute, relative, resolve, sep } from "node:path";
12
+ import { isProtectedPath, isWritingTool } from "./protected-paths.ts";
13
+
14
+ export type PermissionMode = "default" | "acceptEdits" | "plan" | "bypassPermissions" | "dontAsk" | "auto";
15
+ /** "classify" is auto mode's outcome: hand the call to the approval classifier. */
16
+ export type PermissionDecision = "allow" | "deny" | "ask" | "classify";
17
+
18
+ /** Claude Code tool name (lowercased) → One Code tool name. */
19
+ const CC_TOOL_NAMES: Record<string, string> = {
20
+ bash: "bash",
21
+ read: "read",
22
+ edit: "edit",
23
+ write: "write",
24
+ grep: "grep",
25
+ glob: "find",
26
+ find: "find",
27
+ ls: "ls",
28
+ notebookedit: "notebook_edit",
29
+ notebook_edit: "notebook_edit",
30
+ webfetch: "web_fetch",
31
+ web_fetch: "web_fetch",
32
+ websearch: "web_search",
33
+ web_search: "web_search",
34
+ task: "subagent",
35
+ agent: "subagent",
36
+ todowrite: "todo_write",
37
+ todo_write: "todo_write",
38
+ skill: "skill",
39
+ askuserquestion: "ask_user_question",
40
+ ask_user_question: "ask_user_question",
41
+ workflow: "workflow",
42
+ taskcreate: "task_create",
43
+ taskget: "task_get",
44
+ tasklist: "task_list",
45
+ taskupdate: "task_update",
46
+ taskoutput: "task_output",
47
+ taskstop: "task_stop",
48
+ monitor: "monitor",
49
+ schedulewakeup: "schedule_wakeup",
50
+ sendmessage: "send_message",
51
+ enterworktree: "enter_worktree",
52
+ exitworktree: "exit_worktree",
53
+ listmcpresourcestool: "list_mcp_resources",
54
+ readmcpresourcetool: "read_mcp_resource",
55
+ readmcpresourcedirtool: "read_mcp_resource_dir",
56
+ };
57
+
58
+ export function normalizeToolName(name: string): string {
59
+ if (name.startsWith("mcp__")) return name;
60
+ const lower = name.toLowerCase();
61
+ return CC_TOOL_NAMES[lower] ?? lower;
62
+ }
63
+
64
+ /**
65
+ * Every Claude Code-style spelling that maps to this One Code tool name — the
66
+ * inverse of CC_TOOL_NAMES, used by extensions/hooks to test CC hook matchers
67
+ * ("Glob", "Task", …) against One Code tool names. Extending CC_TOOL_NAMES for
68
+ * a new tool keeps hook matching current automatically.
69
+ */
70
+ export function ccAliasesForTool(nativeName: string): string[] {
71
+ return Object.entries(CC_TOOL_NAMES)
72
+ .filter(([, mapped]) => mapped === nativeName)
73
+ .map(([alias]) => alias);
74
+ }
75
+
76
+ export interface PermissionRule {
77
+ raw: string;
78
+ tool: string;
79
+ pattern?: string;
80
+ }
81
+
82
+ /** Parse "Tool" or "Tool(pattern)". Returns undefined for malformed rules. */
83
+ export function parseRule(raw: string): PermissionRule | undefined {
84
+ const trimmed = raw.trim();
85
+ if (!trimmed) return undefined;
86
+ const match = trimmed.match(/^([A-Za-z0-9_]+)(?:\((.*)\))?$/s);
87
+ if (!match) return undefined;
88
+ const [, name, pattern] = match;
89
+ return { raw: trimmed, tool: normalizeToolName(name), pattern: pattern || undefined };
90
+ }
91
+
92
+ export function parseRules(raws: string[]): PermissionRule[] {
93
+ return raws.map(parseRule).filter((r): r is PermissionRule => r !== undefined);
94
+ }
95
+
96
+ /** Glob → RegExp. `**` crosses path separators, `*` does not. */
97
+ function globToRegex(glob: string, pathMode: boolean): RegExp {
98
+ let out = "";
99
+ for (let i = 0; i < glob.length; i++) {
100
+ const ch = glob[i];
101
+ if (ch === "*") {
102
+ if (pathMode && glob[i + 1] === "*") {
103
+ out += ".*";
104
+ i++;
105
+ if (glob[i + 1] === "/") i++; // "**/" also matches zero directories
106
+ } else {
107
+ out += pathMode ? "[^/]*" : ".*";
108
+ }
109
+ } else if ("\\^$.|?+()[]{}".includes(ch)) {
110
+ out += `\\${ch}`;
111
+ } else {
112
+ out += ch;
113
+ }
114
+ }
115
+ return new RegExp(`^${out}$`);
116
+ }
117
+
118
+ /**
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.
122
+ */
123
+ export function matchesBashPattern(pattern: string, command: string): boolean {
124
+ const cmd = command.trim();
125
+ if (pattern.endsWith(":*")) {
126
+ const prefix = pattern.slice(0, -2);
127
+ return cmd === prefix || cmd.startsWith(prefix);
128
+ }
129
+ if (pattern.includes("*")) {
130
+ return globToRegex(pattern, false).test(cmd);
131
+ }
132
+ return cmd === pattern;
133
+ }
134
+
135
+ /** Path pattern match against the raw, absolute, cwd-relative, and ~-expanded forms. */
136
+ export function matchesPathPattern(pattern: string, subject: string, cwd: string): boolean {
137
+ 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);
142
+ candidates.add(absolute);
143
+ const rel = relative(cwd, absolute);
144
+ if (rel && !rel.startsWith("..")) candidates.add(rel);
145
+ if (absolute.startsWith(`${home}/`)) candidates.add(`~/${absolute.slice(home.length + 1)}`);
146
+
147
+ const regex = globToRegex(expandedPattern, true);
148
+ for (const candidate of candidates) {
149
+ if (regex.test(candidate)) return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ /** The argument a rule pattern applies to, per tool. */
155
+ 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 : "";
159
+ }
160
+
161
+ /**
162
+ * Whether `candidate` names plan mode's one writable file, after ~-expansion
163
+ * and cwd resolution of both sides. Compared case-folded: the resolved subject
164
+ * arrives case-folded from `resolveForContainment`, and without folding that
165
+ * branch of the check could never match on macOS.
166
+ */
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. */
169
+ function toAbsoluteFolded(p: string, cwd: string): string {
170
+ const expanded = p.startsWith("~/") ? `${homedir()}/${p.slice(2)}` : p;
171
+ return resolve(cwd, expanded).toLowerCase();
172
+ }
173
+
174
+ export function isPlanFilePath(candidate: string, planFilePath: string, cwd: string): boolean {
175
+ return toAbsoluteFolded(candidate, cwd) === toAbsoluteFolded(planFilePath, cwd);
176
+ }
177
+
178
+ /**
179
+ * Whether `candidate` lands *inside* `dir` (a harness-designated session
180
+ * directory: the auto-memory dir, the scratchpad). `resolve()` normalizes
181
+ * `..` segments first, so a traversal spelled through the dir does not clear.
182
+ * Compared case-folded, like isProtectedPath: the resolved subject arrives
183
+ * case-folded from `resolveForContainment`.
184
+ */
185
+ export function isInsideDir(candidate: string, dir: string, cwd: string): boolean {
186
+ return toAbsoluteFolded(candidate, cwd).startsWith(toAbsoluteFolded(dir, cwd) + sep);
187
+ }
188
+
189
+ export function ruleMatches(rule: PermissionRule, toolName: string, subject: string, cwd: string): boolean {
190
+ if (rule.tool !== normalizeToolName(toolName)) return false;
191
+ if (!rule.pattern) return true;
192
+ if (!subject) return false;
193
+ if (rule.tool === "bash") return matchesBashPattern(rule.pattern, subject);
194
+ return matchesPathPattern(rule.pattern, subject, cwd);
195
+ }
196
+
197
+ /** Risk tier drives the unmatched-rule default. */
198
+ export type ToolTier = "safe" | "edit" | "execute" | "custom";
199
+
200
+ const SAFE_TOOLS = new Set(["read", "grep", "find", "ls"]);
201
+ const EDIT_TOOLS = new Set(["edit", "write", "notebook_edit"]);
202
+
203
+ export function toolTier(toolName: string): ToolTier {
204
+ const name = normalizeToolName(toolName);
205
+ if (SAFE_TOOLS.has(name)) return "safe";
206
+ if (EDIT_TOOLS.has(name)) return "edit";
207
+ if (name === "bash") return "execute";
208
+ return "custom";
209
+ }
210
+
211
+ /**
212
+ * Tools that never need approval: they change no state outside the session and
213
+ * touch no network or filesystem the user hasn't already opted into.
214
+ *
215
+ * `tool_search` and `skill` matter more than they look — gating them breaks
216
+ * deferred tool loading and skill invocation entirely in non-interactive runs,
217
+ * since a blocked loader means the model can never reach the tools behind it.
218
+ */
219
+ export const AUTO_ALLOWED_TOOLS = new Set<string>([
220
+ "todo_write",
221
+ "ask_user_question",
222
+ "ask_user",
223
+ // Loads tool schemas and packaged instructions; no side effects.
224
+ "tool_search",
225
+ "skill",
226
+ // Read-only inspection.
227
+ "lsp_diagnostics",
228
+ "list_mcp_resources",
229
+ // 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",
233
+ // Same reasoning for workflow: the script itself cannot touch the
234
+ // filesystem, network, or shell — only spawn agents, and every one of those
235
+ // runs behind the workflow permission gate.
236
+ "workflow",
237
+ // Plan-mode transitions must work inside plan mode itself.
238
+ "enter_plan_mode",
239
+ "exit_plan_mode",
240
+ // Session-state bookkeeping, like todo_write.
241
+ "task_create",
242
+ "task_get",
243
+ "task_list",
244
+ "task_update",
245
+ // Inspecting/stopping background work this session already started.
246
+ "task_output",
247
+ "task_stop",
248
+ // A timer that replays a prompt; no side effects outside the session.
249
+ "schedule_wakeup",
250
+ // Resumes a subagent — same reasoning as subagent: the child enforces its
251
+ // own tool permissions. (monitor and enter/exit_worktree stay gated: they
252
+ // run arbitrary shell commands / mutate the filesystem.)
253
+ "send_message",
254
+ ]);
255
+
256
+ export interface DecideInput {
257
+ toolName: string;
258
+ subject: string;
259
+ cwd: string;
260
+ mode: PermissionMode;
261
+ deny: PermissionRule[];
262
+ ask: PermissionRule[];
263
+ allow: PermissionRule[];
264
+ /** Auto mode: route every shell command through the classifier, ignoring narrow Bash allow rules. */
265
+ classifyAllShell?: boolean;
266
+ /**
267
+ * The subject with symlinks resolved (see auto-mode/paths.ts), when the
268
+ * caller could resolve it. The protected-path check consults it as well as
269
+ * the literal spelling, so writing through `ln -s .git/hooks build` is as
270
+ * protected as writing `.git/hooks` directly. Kept a separate input so this
271
+ * module stays pure — resolution touches the filesystem.
272
+ */
273
+ resolvedSubject?: string;
274
+ /**
275
+ * Plan mode's one writable file (see extensions/plan-mode). Absolute or
276
+ * ~-prefixed; writes whose subject resolves to it are allowed even in plan
277
+ * mode.
278
+ */
279
+ planFilePath?: string;
280
+ /**
281
+ * The session's auto-memory directory (see extensions/memory). Absolute or
282
+ * ~-prefixed; writes landing inside it are allowed — the system prompt
283
+ * itself instructs them — though deny and ask rules still win.
284
+ */
285
+ memoryDirPath?: string;
286
+ /**
287
+ * The session's scratchpad directory (see extensions/lib/scratchpad); the
288
+ * system prompt directs all temp files there, with the same treatment as
289
+ * the memory dir.
290
+ */
291
+ scratchpadDirPath?: string;
292
+ }
293
+
294
+ export interface Decision {
295
+ decision: PermissionDecision;
296
+ /** Rule that determined the outcome, when one did. */
297
+ rule?: PermissionRule;
298
+ /** 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";
300
+ }
301
+
302
+ /**
303
+ * Tools that launch a fresh agent loop. In auto mode these are classified
304
+ * rather than auto-allowed, so the delegated task is judged before the child
305
+ * starts — a child cannot be trusted to refuse a task its parent should not have
306
+ * handed it, and Claude Code evaluates the task description at spawn time for
307
+ * the same reason. Outside auto mode they stay auto-allowed: each child enforces
308
+ * its own permissions by inheriting the mode.
309
+ */
310
+ const DELEGATION_TOOLS = new Set(["subagent", "workflow"]);
311
+
312
+ /**
313
+ * Interpreters and runners whose arguments are code, so a wildcarded rule over
314
+ * them (`Bash(python*)`, `Bash(npm run *)`) grants arbitrary execution just as
315
+ * surely as `Bash(*)` does.
316
+ */
317
+ const INTERPRETERS_AND_RUNNERS =
318
+ /^(python[0-9.]*|python3|node|deno|bun|ruby|perl|php|osascript|bash|sh|zsh|fish|pwsh|powershell|eval|exec|env|xargs|nohup|setsid|timeout|make|npx|pnpx|yarn|npm|pnpm|bunx|uv|uvx|pip[0-9]*|poetry|cargo|go|dotnet|java|mvn|gradle|docker|kubectl|ssh)\b/;
319
+
320
+ /**
321
+ * An allow rule broad enough to grant arbitrary code execution. Auto mode
322
+ * suspends these — a blanket `Bash` or a wildcarded interpreter would otherwise
323
+ * hand the model a standing way past the classifier, which is the one thing auto
324
+ * mode exists to prevent. Narrow rules (`Bash(npm test:*)`) still resolve before
325
+ * the classifier unless `classifyAllShell` is set.
326
+ *
327
+ * Matches Claude Code's list: blanket `Bash(*)`/`PowerShell(*)`, wildcarded
328
+ * interpreters, package-manager run commands, and `Agent`/`Task` rules.
329
+ */
330
+ export function isBroadExecutionRule(rule: PermissionRule): boolean {
331
+ // Delegation rules are dropped outright: a subagent is a fresh agent loop, so
332
+ // pre-approving one pre-approves whatever that loop decides to do.
333
+ if (rule.tool === "subagent" || rule.tool === "workflow") return true;
334
+ if (rule.tool !== "bash") return false;
335
+
336
+ if (!rule.pattern) return true;
337
+ const pattern = rule.pattern.trim().toLowerCase();
338
+ // Without a wildcard the rule matches one exact command, which is narrow by
339
+ // construction however powerful that command is — `Bash(python)` only ever
340
+ // starts a bare REPL.
341
+ if (!pattern.includes("*")) return false;
342
+ if (/^(\*|:\*|\*\*)$/.test(pattern)) return true;
343
+
344
+ const head = pattern.split(/[\s*]/)[0] ?? "";
345
+ if (!INTERPRETERS_AND_RUNNERS.test(head)) return false;
346
+
347
+ const rest = pattern.slice(head.length).trim();
348
+ // Nothing constrains the arguments: `python*`, `node *`, `npm *`.
349
+ if (/^[:\s]*\*+$/.test(rest)) return true;
350
+ // The runner's own escape hatch takes arbitrary code: `npm run *`, `npx *`.
351
+ if (/^(run|exec|x)[:\s]*\*+$/.test(rest)) return true;
352
+ // 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;
357
+ // `npm test:*` names the script, so it stays narrow.
358
+ return false;
359
+ }
360
+
361
+ export function decide(params: DecideInput): Decision {
362
+ const { toolName, subject, cwd, mode, deny, ask, allow } = params;
363
+
364
+ // In dontAsk mode anything that would prompt is denied instead — including
365
+ // explicit ask rules: there is no user to put the question to.
366
+ const askOrDeny = (rule?: PermissionRule): Decision => {
367
+ if (mode === "dontAsk") return { decision: "deny", rule, cause: "mode" };
368
+ return { decision: "ask", rule, cause: rule ? "rule" : "tier" };
369
+ };
370
+
371
+ const denyRule = deny.find((r) => ruleMatches(r, toolName, subject, cwd));
372
+ if (denyRule) return { decision: "deny", rule: denyRule, cause: "rule" };
373
+
374
+ if (mode === "bypassPermissions") return { decision: "allow", cause: "mode" };
375
+
376
+ const tier = toolTier(toolName);
377
+ if (mode === "plan" && tier !== "safe" && !AUTO_ALLOWED_TOOLS.has(normalizeToolName(toolName))) {
378
+ // Plan mode's one writable file (~/.one-code/plans/<slug>.md, which
379
+ // protected-paths already excepts as working space).
380
+ const planFile = params.planFilePath;
381
+ if (planFile && isWritingTool(normalizeToolName(toolName)) && subject) {
382
+ const planTarget =
383
+ isPlanFilePath(subject, planFile, cwd) ||
384
+ (params.resolvedSubject ? isPlanFilePath(params.resolvedSubject, planFile, cwd) : false);
385
+ if (planTarget) return { decision: "allow", cause: "plan-file" };
386
+ }
387
+ return { decision: "deny", cause: "plan-mode" };
388
+ }
389
+
390
+ // An explicit ask rule is the user's stated intent to be prompted, so it wins
391
+ // over auto mode too: the classifier never gets to auto-approve a match.
392
+ const askRule = ask.find((r) => ruleMatches(r, toolName, subject, cwd));
393
+ if (askRule) return askOrDeny(askRule);
394
+
395
+ /**
396
+ * The session's own auto-memory and scratchpad directories are
397
+ * harness-designated working space, like plan mode's plan file: the system
398
+ * prompt instructs the model to write memories and temp files there, so
399
+ * gating those writes (outside-cwd, and for memory under the protected
400
+ * `.claude` dir) makes the harness block its own feature — auto mode's
401
+ * classifier was correctly flagging them as out-of-project writes. This
402
+ * clears *only* the exact per-session dirs passed in; any other path under
403
+ * `.claude` or `/tmp` gets no special treatment. The resolved form is
404
+ * where the write actually lands, so it is the one judged — a symlink
405
+ * planted inside a session dir must not turn this into an allow for
406
+ * wherever it points.
407
+ */
408
+ if (isWritingTool(normalizeToolName(toolName)) && subject) {
409
+ const target = params.resolvedSubject ?? subject;
410
+ for (const { dir, cause } of [
411
+ { dir: params.memoryDirPath, cause: "memory-dir" as const },
412
+ { dir: params.scratchpadDirPath, cause: "scratchpad-dir" as const },
413
+ ]) {
414
+ if (dir && isInsideDir(target, dir, cwd)) return { decision: "allow", cause };
415
+ }
416
+ }
417
+
418
+ // Protected-path writes are checked *before* allow rules, so an
419
+ // `Edit(.claude/**)` entry cannot pre-approve reconfiguring the agent's own
420
+ // permissions or planting a git hook. In auto mode they go to the classifier.
421
+ const tool = normalizeToolName(toolName);
422
+ const protectedTarget = () =>
423
+ isProtectedPath(subject, cwd) || (params.resolvedSubject ? isProtectedPath(params.resolvedSubject, cwd) : false);
424
+ if (isWritingTool(tool) && subject && protectedTarget()) {
425
+ if (mode === "dontAsk") return { decision: "deny", cause: "protected-path" };
426
+ if (mode === "auto") return { decision: "classify", cause: "protected-path" };
427
+ return { decision: "ask", cause: "protected-path" };
428
+ }
429
+
430
+ const usableAllow =
431
+ mode === "auto"
432
+ ? allow.filter((rule) => {
433
+ if (isBroadExecutionRule(rule)) return false;
434
+ if (params.classifyAllShell && rule.tool === "bash") return false;
435
+ return true;
436
+ })
437
+ : allow;
438
+ const allowRule = usableAllow.find((r) => ruleMatches(r, toolName, subject, cwd));
439
+ if (allowRule) return { decision: "allow", rule: allowRule, cause: "rule" };
440
+
441
+ if (mode === "auto" && DELEGATION_TOOLS.has(tool)) {
442
+ return { decision: "classify", cause: "mode" };
443
+ }
444
+
445
+ if (tier === "safe" || AUTO_ALLOWED_TOOLS.has(tool)) {
446
+ return { decision: "allow", cause: "tier" };
447
+ }
448
+ if (tier === "edit" && mode === "acceptEdits") return { decision: "allow", cause: "mode" };
449
+
450
+ // Everything left over goes to the classifier in auto mode, and to the user
451
+ // in every other mode.
452
+ if (mode === "auto") return { decision: "classify", cause: "mode" };
453
+
454
+ return askOrDeny();
455
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Permission-mode cycling and display (pure).
3
+ *
4
+ * Mirrors Claude Code v2.1: the cycle is manual (`default`) → acceptEdits →
5
+ * plan → [bypassPermissions] → [auto]. bypassPermissions joins only when the
6
+ * session was started with it enabled; `auto` joins when a classifier model is
7
+ * reachable, mirroring how Claude Code drops auto from the cycle where its
8
+ * requirements aren't met. `dontAsk` never appears in the cycle in Claude Code
9
+ * either — it is reachable only via flag or settings.
10
+ */
11
+
12
+ import type { PermissionMode } from "./matcher.ts";
13
+
14
+ /** Footer badge per mode — Claude Code's exact strings and icons. */
15
+ export const MODE_BADGES: Record<PermissionMode, string> = {
16
+ default: "⏸ manual mode on",
17
+ acceptEdits: "⏵⏵ accept edits on",
18
+ plan: "⏸ plan mode on",
19
+ bypassPermissions: "⏵⏵ bypass permissions on",
20
+ dontAsk: "⏵⏵ don't ask on",
21
+ auto: "⏵⏵ auto mode on",
22
+ };
23
+
24
+ const CYCLE: readonly PermissionMode[] = ["default", "acceptEdits", "plan"];
25
+
26
+ export interface CycleOptions {
27
+ bypassInCycle: boolean;
28
+ autoInCycle: boolean;
29
+ }
30
+
31
+ /**
32
+ * Shorten a model id for the footer, which has little room. Drops an OpenRouter
33
+ * style `vendor/` prefix and a trailing date stamp, both of which are noise once
34
+ * the family name is visible: `anthropic/claude-haiku-4-5-20251001` → `haiku-4-5`.
35
+ */
36
+ export function shortModelName(id: string): string {
37
+ const afterSlash = id.slice(id.lastIndexOf("/") + 1);
38
+ const undated = afterSlash.replace(/-20\d{6}$/, "");
39
+ return undated.replace(/^(claude|gpt|gemini|llama|grok|mistral|deepseek|qwen)-/, "");
40
+ }
41
+
42
+ /**
43
+ * The key that cycles permission modes. Claude Code cycles on shift+tab and
44
+ * says so in this footer; pi reserves shift+tab for the effort dial, so One Code
45
+ * cycles on ctrl+q (see docs/decisions/modes.md) and the hint names that key.
46
+ */
47
+ export const CYCLE_KEY_HINT = "ctrl+q to cycle";
48
+
49
+ /**
50
+ * The footer badge, with auto mode naming the model screening the calls. That
51
+ * model reads the user's prompts and their CLAUDE.md, so which one it is belongs
52
+ * on screen rather than buried in a command — and before the first call settles
53
+ * it, saying nothing is more honest than guessing. Ends with the cycle-key hint,
54
+ * the way Claude Code's footer does, so the shortcut sits next to the state it
55
+ * changes.
56
+ */
57
+ export function modeBadge(mode: PermissionMode, opts?: { paused?: boolean; classifierModel?: string }): string {
58
+ const base = badgeBase(mode, opts);
59
+ return `${base} (${CYCLE_KEY_HINT})`;
60
+ }
61
+
62
+ function badgeBase(mode: PermissionMode, opts?: { paused?: boolean; classifierModel?: string }): string {
63
+ if (mode !== "auto") return MODE_BADGES[mode];
64
+ const suffix = opts?.classifierModel ? ` · ${shortModelName(opts.classifierModel)}` : "";
65
+ return opts?.paused ? `⏸ auto mode paused${suffix}` : `${MODE_BADGES.auto}${suffix}`;
66
+ }
67
+
68
+ /**
69
+ * Cross-extension channel carrying the current mode and classifier, so the
70
+ * startup banner can show them live. jiti gives each extension its own module
71
+ * instance, so this goes over `pi.events` rather than shared state.
72
+ */
73
+ export const PERMISSION_STATUS_CHANNEL = "one-code:permission-status";
74
+
75
+ export interface PermissionStatus {
76
+ mode: string;
77
+ paused: boolean;
78
+ /** `provider/id` of the classifier screening calls (or about to). */
79
+ classifier?: string;
80
+ /** Whether `classifier` is pinned by a real call, or still the planned first candidate. */
81
+ pinned?: boolean;
82
+ }
83
+
84
+ /**
85
+ * The banner's mode line. In auto mode it names the classifier — that model
86
+ * reads the user's prompts and CLAUDE.md, so which one it is belongs on screen
87
+ * — and says when the name is still the plan rather than the settled fact.
88
+ */
89
+ export function permissionModeDisplay(status: PermissionStatus): string {
90
+ if (status.mode !== "auto") return status.mode;
91
+ const name = status.classifier ? shortModelName(status.classifier) : "no model available";
92
+ if (status.paused) return `auto (paused) · classifier ${name}`;
93
+ return `auto · classifier ${name}${status.pinned ? "" : " (planned)"}`;
94
+ }
95
+
96
+ /**
97
+ * The mode the cycle shortcut lands on next. A mode outside the cycle
98
+ * (dontAsk, or bypass in a session that didn't start with it) exits to the
99
+ * cycle's start rather than being unreachable-from.
100
+ */
101
+ export function nextMode(current: PermissionMode, opts: CycleOptions): PermissionMode {
102
+ const cycle: PermissionMode[] = [...CYCLE];
103
+ if (opts.bypassInCycle) cycle.push("bypassPermissions");
104
+ if (opts.autoInCycle) cycle.push("auto");
105
+ const index = cycle.indexOf(current);
106
+ return cycle[(index + 1) % cycle.length];
107
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Protected paths (pure) — Claude Code's list of paths whose *writes* are never
3
+ * auto-approved, in any mode except bypassPermissions.
4
+ *
5
+ * These are not "secret" paths (that list lives in `auto-mode/sensitive.ts` and
6
+ * is about credentials). These are paths that configure the tooling: a write to
7
+ * `.git/hooks`, `.husky`, `.pre-commit-config.yaml`, a shell rc file, or an
8
+ * `.npmrc` plants code that runs on the next commit, install, or shell — and a
9
+ * write to `.claude` reconfigures the agent's own permissions. Being inside the
10
+ * working directory says nothing about whether such a write is safe, which is
11
+ * why containment does not clear them and why an `allow` rule does not either:
12
+ * the check runs before allow rules are evaluated.
13
+ */
14
+
15
+ import { relative, resolve, sep } from "node:path";
16
+
17
+ /** Directories whose entire contents are protected. */
18
+ const PROTECTED_DIRS = [
19
+ ".git",
20
+ ".config/git",
21
+ ".vscode",
22
+ ".idea",
23
+ ".husky",
24
+ ".cargo",
25
+ ".devcontainer",
26
+ ".yarn",
27
+ ".mvn",
28
+ ".claude",
29
+ // One Code's own state dir — the deny-direction twin of protecting `.claude`:
30
+ // as generated state moves here, a write that reconfigures the gate must
31
+ // stay just as guarded in its new home.
32
+ ".one-code",
33
+ ];
34
+
35
+ /**
36
+ * `.claude/worktrees` is where the agent keeps its own git worktrees, and
37
+ * `.one-code/plans` holds plan-mode documents (rendered to the user, never
38
+ * executed) — ordinary working space rather than configuration.
39
+ */
40
+ const PROTECTED_DIR_EXCEPTIONS = [".claude/worktrees", ".one-code/plans"];
41
+
42
+ const PROTECTED_FILES = new Set([
43
+ ".gitconfig",
44
+ ".gitmodules",
45
+ ".bashrc",
46
+ ".bash_profile",
47
+ ".bash_login",
48
+ ".bash_aliases",
49
+ ".bash_logout",
50
+ ".zshrc",
51
+ ".zprofile",
52
+ ".zshenv",
53
+ ".zlogin",
54
+ ".zlogout",
55
+ ".profile",
56
+ ".envrc",
57
+ ".npmrc",
58
+ ".yarnrc",
59
+ ".yarnrc.yml",
60
+ ".pnp.cjs",
61
+ ".pnp.loader.mjs",
62
+ ".pnpmfile.cjs",
63
+ "bunfig.toml",
64
+ ".bunfig.toml",
65
+ ".bazelrc",
66
+ ".bazelversion",
67
+ ".bazeliskrc",
68
+ ".pre-commit-config.yaml",
69
+ "lefthook.yml",
70
+ "lefthook.yaml",
71
+ ".lefthook.yml",
72
+ ".lefthook.yaml",
73
+ "gradle-wrapper.properties",
74
+ "maven-wrapper.properties",
75
+ ".devcontainer.json",
76
+ ".ripgreprc",
77
+ "pyrightconfig.json",
78
+ ".mcp.json",
79
+ ".claude.json",
80
+ ]);
81
+
82
+ // `isWritingTool` / `WRITING_TOOLS` live in auto-mode/paths.ts (the shared lower
83
+ // layer) so the safety floor and this gate share one source of truth; re-exported
84
+ // here for the existing importers (matcher.ts, permissions/index.ts).
85
+ export { isWritingTool } from "../auto-mode/paths.ts";
86
+
87
+ /**
88
+ * Whether a path is protected. Matched on path *segments* so the check works
89
+ * for absolute paths, cwd-relative ones, and any depth of nesting — a
90
+ * `packages/app/.vscode/settings.json` is as protected as a top-level one.
91
+ */
92
+ export function isProtectedPath(candidate: string, cwd?: string): boolean {
93
+ const forward = candidate.replace(/\\/g, "/").toLowerCase();
94
+ // Compare relative to cwd when given, so an absolute in-project path and the
95
+ // relative form of the same path behave identically.
96
+ const relativeForm = cwd
97
+ ? relative(resolve(cwd), resolve(cwd, candidate)).split(sep).join("/").toLowerCase()
98
+ : forward;
99
+ const forms = [forward, relativeForm].filter(Boolean);
100
+
101
+ for (const form of forms) {
102
+ const segments = form.split("/").filter((segment) => segment && segment !== ".");
103
+ if (segments.length === 0) continue;
104
+
105
+ const basename = segments[segments.length - 1];
106
+ if (PROTECTED_FILES.has(basename)) return true;
107
+
108
+ for (const dir of PROTECTED_DIRS) {
109
+ const dirSegments = dir.split("/");
110
+ for (let i = 0; i + dirSegments.length <= segments.length; i++) {
111
+ if (!dirSegments.every((part, offset) => segments[i + offset] === part)) continue;
112
+ // The match is only protective if something lives *under* the
113
+ // directory — the write target has to be inside it.
114
+ if (i + dirSegments.length >= segments.length) continue;
115
+ const tail = segments.slice(i).join("/");
116
+ if (PROTECTED_DIR_EXCEPTIONS.some((exception) => tail.startsWith(`${exception}/`) || tail === exception)) {
117
+ continue;
118
+ }
119
+ return true;
120
+ }
121
+ }
122
+ }
123
+
124
+ return false;
125
+ }