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.
- package/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- 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
|
+
}
|