one-code-extension 0.3.1 → 0.4.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.
Files changed (113) hide show
  1. package/README.md +214 -147
  2. package/docs/guide/README.md +1 -0
  3. package/docs/guide/configuration.md +2 -2
  4. package/docs/guide/differences-from-claude-code.md +13 -4
  5. package/docs/guide/hooks.md +14 -2
  6. package/docs/guide/installation.md +54 -6
  7. package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
  8. package/docs/guide/reference.md +5 -2
  9. package/docs/guide/sessions-and-context.md +18 -0
  10. package/docs/guide/terminal-interface.md +1 -1
  11. package/docs/guide/tools.md +24 -2
  12. package/docs/guide/troubleshooting.md +14 -4
  13. package/docs/guide/windows.md +189 -0
  14. package/extensions/auto-mode/actions.ts +3 -1
  15. package/extensions/auto-mode/paths.ts +21 -23
  16. package/extensions/auto-mode/safety-floor.ts +44 -6
  17. package/extensions/auto-mode/shell-analysis.ts +25 -10
  18. package/extensions/auto-mode/transcript.ts +15 -5
  19. package/extensions/background/index.ts +83 -34
  20. package/extensions/background/monitor-batch.ts +7 -5
  21. package/extensions/background/wakeup.ts +18 -24
  22. package/extensions/bash/background.ts +19 -10
  23. package/extensions/bash/guards.ts +36 -16
  24. package/extensions/bash/index.ts +36 -166
  25. package/extensions/branding/index.ts +35 -4
  26. package/extensions/btw/index.ts +207 -0
  27. package/extensions/btw/panel.ts +179 -0
  28. package/extensions/btw/prompt.ts +45 -0
  29. package/extensions/clear/index.ts +15 -0
  30. package/extensions/compaction/fit.ts +9 -6
  31. package/extensions/doctor/build.ts +22 -2
  32. package/extensions/doctor/dependencies.ts +63 -31
  33. package/extensions/doctor/fix-prompt.ts +12 -4
  34. package/extensions/doctor/index.ts +1 -0
  35. package/extensions/doctor/models.ts +2 -1
  36. package/extensions/doctor/report.ts +2 -3
  37. package/extensions/doctor/update-lookup.ts +19 -11
  38. package/extensions/effort/index.ts +2 -1
  39. package/extensions/footer/footer-line.ts +19 -0
  40. package/extensions/footer/index.ts +25 -5
  41. package/extensions/hooks/executor.ts +84 -18
  42. package/extensions/hooks/index.ts +1 -0
  43. package/extensions/hooks/matcher.ts +23 -9
  44. package/extensions/hooks/settings.ts +15 -0
  45. package/extensions/lib/branch-restore.ts +12 -0
  46. package/extensions/lib/claude-context.ts +2 -3
  47. package/extensions/lib/claude-settings.ts +19 -0
  48. package/extensions/lib/command-launch.ts +71 -0
  49. package/extensions/lib/deferred.ts +173 -33
  50. package/extensions/lib/git.ts +16 -0
  51. package/extensions/lib/keys.ts +38 -0
  52. package/extensions/lib/local-command.ts +112 -0
  53. package/extensions/lib/model-facts.generated.json +263 -68
  54. package/extensions/lib/model-policy.ts +13 -0
  55. package/extensions/lib/model-tier.ts +16 -5
  56. package/extensions/lib/notifications.ts +547 -53
  57. package/extensions/lib/paths.ts +130 -2
  58. package/extensions/lib/pi-ai-estimate.ts +57 -26
  59. package/extensions/lib/pi-version.ts +1 -1
  60. package/extensions/lib/process-tree.ts +131 -5
  61. package/extensions/lib/reminders.ts +80 -27
  62. package/extensions/lib/scratchpad.ts +11 -10
  63. package/extensions/lib/shell-spawn.ts +298 -0
  64. package/extensions/lib/shell-tool.ts +252 -0
  65. package/extensions/lib/shell-tools.ts +13 -0
  66. package/extensions/lib/side-call.ts +76 -0
  67. package/extensions/lib/tui-render.ts +62 -11
  68. package/extensions/lib/update-check.mjs +155 -0
  69. package/extensions/lib/usage-bus.ts +1 -1
  70. package/extensions/lib/which.ts +62 -0
  71. package/extensions/lib/worktree-channel.ts +18 -0
  72. package/extensions/lib/worktree-isolation.ts +2 -1
  73. package/extensions/lsp/client.ts +14 -3
  74. package/extensions/lsp/index.ts +12 -5
  75. package/extensions/lsp/install-hints.ts +33 -3
  76. package/extensions/lsp/plugin-servers.ts +2 -2
  77. package/extensions/lsp/servers.ts +29 -7
  78. package/extensions/mcp/index.ts +3 -2
  79. package/extensions/memory/entries.ts +9 -6
  80. package/extensions/memory/index.ts +3 -2
  81. package/extensions/memory/open-external.ts +4 -1
  82. package/extensions/model-default/index.ts +21 -2
  83. package/extensions/permissions/index.ts +57 -28
  84. package/extensions/permissions/matcher.ts +120 -33
  85. package/extensions/permissions/modes.ts +14 -6
  86. package/extensions/permissions/powershell-rules.ts +549 -0
  87. package/extensions/permissions/session-grant.ts +9 -9
  88. package/extensions/plugins/index.ts +3 -2
  89. package/extensions/plugins/install/install.ts +1 -1
  90. package/extensions/powershell/description.ts +139 -0
  91. package/extensions/powershell/guards.ts +91 -0
  92. package/extensions/powershell/index.ts +89 -0
  93. package/extensions/powershell/policy.ts +91 -0
  94. package/extensions/recap/index.ts +10 -24
  95. package/extensions/recap/prompt.ts +3 -3
  96. package/extensions/session-title/index.ts +120 -0
  97. package/extensions/session-title/title.ts +111 -0
  98. package/extensions/skill/index.ts +40 -18
  99. package/extensions/skill/invoke.ts +1 -0
  100. package/extensions/subagents/hand-back-review.ts +10 -12
  101. package/extensions/subagents/index.ts +221 -78
  102. package/extensions/subagents/model-select.ts +75 -7
  103. package/extensions/system-prompt/environment.ts +14 -1
  104. package/extensions/system-reminder/index.ts +9 -0
  105. package/extensions/tasks/index.ts +2 -1
  106. package/extensions/tool-search/index.ts +41 -20
  107. package/extensions/workflow/agent-session.ts +6 -4
  108. package/extensions/workflow/index.ts +28 -5
  109. package/extensions/worktree/guards.ts +5 -4
  110. package/extensions/worktree/index.ts +15 -2
  111. package/extensions/worktree/rewrite.ts +17 -0
  112. package/package.json +9 -5
  113. package/types/app.d.ts +18 -2
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Claude Code's `PowerShell(...)` permission-rule semantics (pure).
3
+ *
4
+ * The rule shape is Bash's — `PowerShell(git status:*)`, wildcards, a bare
5
+ * `PowerShell` — but the command line is PowerShell's, so three things differ
6
+ * (findings §22, the reconstructed `PowerShellTool/powershellPermissions.ts`):
7
+ *
8
+ * - **Alias canonicalization.** `ls`, `dir`, `gci` are all `Get-ChildItem`;
9
+ * `rm`, `del`, `ri` are all `Remove-Item`. A rule and a command are compared
10
+ * after the leading command word is resolved through Claude Code's alias
11
+ * table (`COMMON_ALIASES`, copied) and lowercased — cmdlet names are
12
+ * case-insensitive. Aliases PowerShell 7 dropped because they collide with
13
+ * native executables (`sort`, `sc`, `curl`, `wget`) are deliberately absent,
14
+ * as in CC: mapping `sort` to `Sort-Object` would judge the wrong program.
15
+ * - **Statement split.** A command line is split on `|`, `;`, `&&`, `||` and
16
+ * unquoted newlines, outside `'…'`, `"…"` and `@'…'@` / `@"…"@` here-strings,
17
+ * with backtick escapes honoured. For an allow rule every part must match
18
+ * (CC: "every subcommand must match"); a deny/ask rule fires on any part.
19
+ * Unbalanced quoting is a parse failure → no allow, the user is asked.
20
+ * - **No AST.** Claude Code parses the command; v1 here is textual and
21
+ * conservative in the only direction that matters: a wildcard or prefix
22
+ * allow never covers a line whose meaning is not on its face — `$(…)`
23
+ * subexpressions, backtick escapes, the `&`/`.` call operators,
24
+ * `Invoke-Expression`, `-EncodedCommand`, script blocks.
25
+ *
26
+ * `powershellReadOnly` ports the read-only allowlist Claude Code uses to skip
27
+ * approval — the cmdlet sets from the 2.1.276 binary — and adds path
28
+ * containment by shape: a read of an absolute, `~`, drive-lettered, UNC or
29
+ * `..` path is not auto-cleared (UNC paths always ask in CC). Anything the
30
+ * check cannot vouch for is simply "not read-only", which costs a classifier
31
+ * call or a prompt, never a bypass — docs/decisions/windows.md.
32
+ */
33
+
34
+ import { isAbsolute } from "node:path";
35
+ import { isWithin, resolveForContainment, toAbsolute } from "../auto-mode/paths.ts";
36
+
37
+ /** Claude Code's `COMMON_ALIASES` (utils/powershell/parser.ts), alias → canonical cmdlet. */
38
+ export const POWERSHELL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
39
+ ls: "Get-ChildItem",
40
+ dir: "Get-ChildItem",
41
+ gci: "Get-ChildItem",
42
+ cat: "Get-Content",
43
+ type: "Get-Content",
44
+ gc: "Get-Content",
45
+ cd: "Set-Location",
46
+ sl: "Set-Location",
47
+ chdir: "Set-Location",
48
+ pushd: "Push-Location",
49
+ popd: "Pop-Location",
50
+ pwd: "Get-Location",
51
+ gl: "Get-Location",
52
+ gi: "Get-Item",
53
+ gp: "Get-ItemProperty",
54
+ ni: "New-Item",
55
+ mkdir: "New-Item",
56
+ md: "New-Item",
57
+ ri: "Remove-Item",
58
+ del: "Remove-Item",
59
+ rd: "Remove-Item",
60
+ rmdir: "Remove-Item",
61
+ rm: "Remove-Item",
62
+ erase: "Remove-Item",
63
+ mi: "Move-Item",
64
+ mv: "Move-Item",
65
+ move: "Move-Item",
66
+ ci: "Copy-Item",
67
+ cp: "Copy-Item",
68
+ copy: "Copy-Item",
69
+ cpi: "Copy-Item",
70
+ si: "Set-Item",
71
+ rni: "Rename-Item",
72
+ ren: "Rename-Item",
73
+ ps: "Get-Process",
74
+ gps: "Get-Process",
75
+ kill: "Stop-Process",
76
+ spps: "Stop-Process",
77
+ start: "Start-Process",
78
+ saps: "Start-Process",
79
+ sajb: "Start-Job",
80
+ ipmo: "Import-Module",
81
+ echo: "Write-Output",
82
+ write: "Write-Output",
83
+ sleep: "Start-Sleep",
84
+ help: "Get-Help",
85
+ man: "Get-Help",
86
+ gcm: "Get-Command",
87
+ gsv: "Get-Service",
88
+ gv: "Get-Variable",
89
+ sv: "Set-Variable",
90
+ h: "Get-History",
91
+ history: "Get-History",
92
+ iex: "Invoke-Expression",
93
+ iwr: "Invoke-WebRequest",
94
+ irm: "Invoke-RestMethod",
95
+ icm: "Invoke-Command",
96
+ ii: "Invoke-Item",
97
+ nsn: "New-PSSession",
98
+ etsn: "Enter-PSSession",
99
+ exsn: "Exit-PSSession",
100
+ gsn: "Get-PSSession",
101
+ rsn: "Remove-PSSession",
102
+ cls: "Clear-Host",
103
+ clear: "Clear-Host",
104
+ select: "Select-Object",
105
+ where: "Where-Object",
106
+ foreach: "ForEach-Object",
107
+ "%": "ForEach-Object",
108
+ "?": "Where-Object",
109
+ measure: "Measure-Object",
110
+ ft: "Format-Table",
111
+ fl: "Format-List",
112
+ fw: "Format-Wide",
113
+ oh: "Out-Host",
114
+ ogv: "Out-GridView",
115
+ ac: "Add-Content",
116
+ clc: "Clear-Content",
117
+ tee: "Tee-Object",
118
+ epcsv: "Export-Csv",
119
+ sp: "Set-ItemProperty",
120
+ rp: "Remove-ItemProperty",
121
+ cli: "Clear-Item",
122
+ epal: "Export-Alias",
123
+ sls: "Select-String",
124
+ });
125
+
126
+ const aliasLookup = new Map(Object.entries(POWERSHELL_ALIASES).map(([alias, canonical]) => [alias.toLowerCase(), canonical]));
127
+
128
+ /** Windows PATHEXT suffixes PowerShell resolves for a path-free command name. */
129
+ const PATHEXT = /\.(exe|cmd|bat|com)$/i;
130
+
131
+ /**
132
+ * The canonical spelling of a command word: alias → cmdlet, a path-free
133
+ * `git.exe` → `git` (so git rules match either spelling). Case is preserved
134
+ * for display; comparisons lowercase both sides.
135
+ */
136
+ export function canonicalCommandName(word: string): string {
137
+ const trimmed = word.trim();
138
+ if (!trimmed) return trimmed;
139
+ const alias = aliasLookup.get(trimmed.toLowerCase());
140
+ if (alias) return alias;
141
+ if (!/[\\/]/.test(trimmed) && PATHEXT.test(trimmed)) {
142
+ const stripped = trimmed.replace(PATHEXT, "");
143
+ // `where.exe` is the native search tool; bare `where` is the Where-Object
144
+ // alias. The extension is what tells them apart, so it stays.
145
+ return aliasLookup.has(stripped.toLowerCase()) ? trimmed : stripped;
146
+ }
147
+ return trimmed;
148
+ }
149
+
150
+ /** Canonicalize the command word of one statement; the rest of the statement is untouched. */
151
+ export function canonicalizeStatement(statement: string): string {
152
+ const trimmed = statement.trim();
153
+ const match = /^(\S+)([\s\S]*)$/.exec(trimmed);
154
+ if (!match) return trimmed;
155
+ return `${canonicalCommandName(match[1])}${match[2]}`;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Statement split
160
+
161
+ /**
162
+ * The separately executing statements of a PowerShell line — what `|`, `;`,
163
+ * `&&`, `||` and unquoted newlines separate — or undefined when quoting is
164
+ * unbalanced. Single quotes, double quotes, here-strings (`@'…'@`, `@"…"@`,
165
+ * whose closer must start a line) and backtick escapes are honoured; a `|`
166
+ * inside `Where-Object { $_ -match 'a|b' }` still splits, conservatively, so
167
+ * an allow rule over a script block has to cover the pieces.
168
+ */
169
+ export function powershellStatements(command: string): string[] | undefined {
170
+ // One decision parses the same line from several places (read-only check,
171
+ // allow-rule split, match forms, guards, git-status detection); a one-entry
172
+ // memo keyed by the exact string keeps that to one scan per command.
173
+ if (lastSplit?.command === command) return lastSplit.statements?.slice();
174
+ const statements = splitStatements(command);
175
+ lastSplit = { command, statements };
176
+ return statements?.slice();
177
+ }
178
+ let lastSplit: { command: string; statements: string[] | undefined } | undefined;
179
+
180
+ function splitStatements(command: string): string[] | undefined {
181
+ const parts: string[] = [];
182
+ let current = "";
183
+ let i = 0;
184
+ const text = command;
185
+ type Quote = "'" | '"' | "@'" | '@"' | undefined;
186
+ let quote: Quote;
187
+ const push = () => {
188
+ const trimmed = current.trim();
189
+ if (trimmed) parts.push(trimmed);
190
+ current = "";
191
+ };
192
+ while (i < text.length) {
193
+ const ch = text[i];
194
+ const next = text[i + 1];
195
+ if (quote === "@'" || quote === '@"') {
196
+ // A here-string closes only with its terminator at the start of a line.
197
+ const closer = quote === "@'" ? "'@" : '"@';
198
+ if ((i === 0 || text[i - 1] === "\n") && text.startsWith(closer, i)) {
199
+ current += closer;
200
+ i += 2;
201
+ quote = undefined;
202
+ continue;
203
+ }
204
+ current += ch;
205
+ i++;
206
+ continue;
207
+ }
208
+ if (quote === "'") {
209
+ current += ch;
210
+ i++;
211
+ if (ch === "'") {
212
+ if (next === "'") {
213
+ current += next;
214
+ i++; // doubled quote is a literal quote
215
+ } else quote = undefined;
216
+ }
217
+ continue;
218
+ }
219
+ if (quote === '"') {
220
+ current += ch;
221
+ i++;
222
+ if (ch === "`" && i < text.length) {
223
+ current += text[i];
224
+ i++;
225
+ continue;
226
+ }
227
+ if (ch === '"') {
228
+ if (next === '"') {
229
+ current += next;
230
+ i++;
231
+ } else quote = undefined;
232
+ }
233
+ continue;
234
+ }
235
+ // Unquoted.
236
+ if (ch === "`" && i + 1 < text.length) {
237
+ current += ch + text[i + 1];
238
+ i += 2;
239
+ continue;
240
+ }
241
+ if (ch === "#") {
242
+ // Line comment runs to end of line.
243
+ const eol = text.indexOf("\n", i);
244
+ i = eol === -1 ? text.length : eol;
245
+ continue;
246
+ }
247
+ if (ch === "@" && (next === "'" || next === '"')) {
248
+ quote = next === "'" ? "@'" : '@"';
249
+ current += ch + next;
250
+ i += 2;
251
+ continue;
252
+ }
253
+ if (ch === "'" || ch === '"') {
254
+ quote = ch;
255
+ current += ch;
256
+ i++;
257
+ continue;
258
+ }
259
+ if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) {
260
+ push();
261
+ i += 2;
262
+ continue;
263
+ }
264
+ if (ch === "|" || ch === ";" || ch === "\n") {
265
+ push();
266
+ i++;
267
+ continue;
268
+ }
269
+ current += ch;
270
+ i++;
271
+ }
272
+ if (quote !== undefined) return undefined;
273
+ push();
274
+ return parts;
275
+ }
276
+
277
+ /** `& cmd` (call operator) or `. script.ps1` (dot-sourcing) at the head of a statement. */
278
+ function startsWithCallOperator(statement: string): boolean {
279
+ const trimmed = statement.trim();
280
+ return trimmed.startsWith("&") || /^\.\s/.test(trimmed);
281
+ }
282
+
283
+ /** The first whitespace-delimited word of a statement, canonicalized and lowercased. */
284
+ export function statementCommand(statement: string): string {
285
+ const word = statement.trim().split(/\s+/)[0] ?? "";
286
+ return canonicalCommandName(word).toLowerCase();
287
+ }
288
+
289
+ /**
290
+ * Why a wildcard or prefix allow rule must not cover this line: constructs
291
+ * whose effect is not what the words say. Exact rules (the literal string the
292
+ * user approved) are unaffected. Returns undefined when none is present.
293
+ */
294
+ export function powershellInjectionSyntax(command: string): string | undefined {
295
+ if (/\$\(/.test(command)) return "a $(…) subexpression";
296
+ if (/`/.test(command)) return "a backtick escape";
297
+ if (/\{/.test(command)) return "a script block";
298
+ if (/-e(nc|ncoded|ncodedcommand)?\b/i.test(command) && /\b(pwsh|powershell)(\.exe)?\b/i.test(command)) return "an encoded command";
299
+ const statements = powershellStatements(command);
300
+ for (const statement of statements ?? []) {
301
+ if (startsWithCallOperator(statement)) return "the call (&) or dot-source operator";
302
+ const cmd = statementCommand(statement);
303
+ if (cmd === "invoke-expression" || cmd === "invoke-command" || cmd === "start-process" || cmd === "start-job") return `\`${cmd}\``;
304
+ if (cmd === "pwsh" || cmd === "powershell" || cmd === "cmd" || cmd === "wsl" || cmd === "bash" || cmd === "sh") return `a nested \`${cmd}\` shell`;
305
+ }
306
+ return undefined;
307
+ }
308
+
309
+ /** The `-Command`/`-c` script of a nested `pwsh …` / `powershell …` statement, if any. */
310
+ function nestedShellScript(statement: string): string | undefined {
311
+ const cmd = statementCommand(statement);
312
+ if (cmd !== "pwsh" && cmd !== "powershell") return undefined;
313
+ const match = /\s-(?:c|command)\s+(.*)$/is.exec(statement);
314
+ if (!match) return undefined;
315
+ const raw = match[1].trim();
316
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(raw);
317
+ return quoted ? quoted[2] : raw;
318
+ }
319
+
320
+ /**
321
+ * Every spelling a deny/ask pattern is tested against: the whole line, each
322
+ * statement raw, each statement with its command word canonicalized, and a
323
+ * nested `pwsh -Command '…'` script expanded recursively — so `PowerShell(Remove-Item:*)`
324
+ * as a deny catches `ls; rm -r x`, `del x`, and `pwsh -c "rm x"` alike. The
325
+ * bash counterpart is `bashMatchForms`; as there, widening here can only make
326
+ * the gate stricter.
327
+ */
328
+ export function powershellMatchForms(command: string, depth = 0): string[] {
329
+ const forms = new Set<string>();
330
+ const trimmed = command.trim();
331
+ if (!trimmed) return [];
332
+ forms.add(trimmed);
333
+ forms.add(canonicalizeStatement(trimmed));
334
+ if (depth > 4) return [...forms];
335
+ for (const statement of powershellStatements(trimmed) ?? []) {
336
+ forms.add(statement);
337
+ forms.add(canonicalizeStatement(statement));
338
+ const nested = nestedShellScript(statement);
339
+ if (nested) for (const form of powershellMatchForms(nested, depth + 1)) forms.add(form);
340
+ }
341
+ return [...forms];
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Read-only allowlist
346
+
347
+ /**
348
+ * The read-only cmdlet sets in the 2.1.276 binary (findings §22), plus the
349
+ * neutral output pair, plus — One Code's addition (2026-09-19, user decision,
350
+ * docs/decisions/windows.md) — the pure in-process pipeline cmdlets a
351
+ * read-only line is piped through: they shape objects already in memory and
352
+ * touch neither disk nor network. `Where-Object`/`ForEach-Object` are NOT
353
+ * here: they take script blocks, which the check refuses anyway. Claude Code's
354
+ * captured list has no pipeline cmdlets at all, so `Select-String … |
355
+ * Measure-Object` (the /doctor transcript scan) always went to the classifier.
356
+ */
357
+ export const READ_ONLY_POWERSHELL_COMMANDS = new Set<string>([
358
+ // search
359
+ "select-string",
360
+ "get-childitem",
361
+ "findstr",
362
+ "where.exe",
363
+ // read
364
+ "get-content",
365
+ "get-item",
366
+ "test-path",
367
+ "resolve-path",
368
+ "get-process",
369
+ "get-service",
370
+ "get-location",
371
+ "get-filehash",
372
+ "get-acl",
373
+ "format-hex",
374
+ // neutral output
375
+ "write-output",
376
+ "write-host",
377
+ // pure pipeline transforms (One Code's addition)
378
+ "select-object",
379
+ "sort-object",
380
+ "measure-object",
381
+ "group-object",
382
+ "convertfrom-json",
383
+ "out-string",
384
+ "format-table",
385
+ "format-list",
386
+ "format-wide",
387
+ "format-custom",
388
+ ]);
389
+
390
+ /** Parameters that turn a read-only cmdlet into a write. */
391
+ const WRITING_PARAMETERS = /^-(outfile|filepath|destination)\b/i;
392
+
393
+ /**
394
+ * Whether a token names a path this check will not vouch for by shape: UNC
395
+ * (`\\server`), home (`~`), a PSDrive outside the filesystem (`HKLM:`,
396
+ * `env:`), or one that climbs (`..`). An absolute or drive-lettered path is
397
+ * judged by `pathOutsideRoots` when roots are known, and refused otherwise.
398
+ */
399
+ function pathUnvouchable(value: string): boolean {
400
+ if (!value) return false;
401
+ if (value.startsWith("\\\\") || value.startsWith("//")) return true;
402
+ if (/^[A-Za-z][A-Za-z0-9]+:/.test(value)) return true; // HKLM:, env:, cert: (a drive is one letter)
403
+ // Drive-relative (`C:foo.txt`, no separator after the colon) means "foo.txt
404
+ // relative to PowerShell's current directory ON C:", which need not be the
405
+ // tool's cwd; `path.isAbsolute` does not call it absolute, so `toAbsolute`
406
+ // would join it onto the cwd and vouch for the wrong file. Refuse by shape.
407
+ if (/^[A-Za-z]:(?![\\/])/.test(value)) return true;
408
+ if (value.startsWith("~")) return true;
409
+ if (/(^|[\\/])\.\.([\\/]|$)/.test(value)) return true;
410
+ return false;
411
+ }
412
+
413
+ /**
414
+ * PowerShell's array syntax splits an argument on commas — outside quotes.
415
+ * `"a,b.txt"` is one path with a comma in its name; `a,"b,c"` is two.
416
+ */
417
+ export function splitPowerShellList(token: string): string[] {
418
+ const parts: string[] = [];
419
+ let current = "";
420
+ let quote: string | undefined;
421
+ for (const ch of token) {
422
+ if (quote) {
423
+ if (ch === quote) quote = undefined;
424
+ else current += ch;
425
+ } else if (ch === '"' || ch === "'") {
426
+ quote = ch;
427
+ } else if (ch === ",") {
428
+ parts.push(current);
429
+ current = "";
430
+ } else {
431
+ current += ch;
432
+ }
433
+ }
434
+ parts.push(current);
435
+ return parts;
436
+ }
437
+
438
+ /** An absolute path by Windows or POSIX spelling: `C:\x`, `C:/x`, `/x`, `\x`. */
439
+ function isAbsoluteSpelling(value: string): boolean {
440
+ return /^[A-Za-z]:/.test(value) || value.startsWith("/") || value.startsWith("\\");
441
+ }
442
+
443
+ export interface PowerShellReadOnlyOptions {
444
+ /** The working directory the command runs in. */
445
+ cwd: string;
446
+ /** The user's home, for `toAbsolute`. */
447
+ home: string;
448
+ /**
449
+ * REALPATH-resolved directories an absolute path may point into besides the
450
+ * working directory: the harness's own session dirs (memory, scratchpad,
451
+ * persisted results, this project's transcripts — matcher.ts `DecideInput`),
452
+ * resolved once per session by the caller.
453
+ */
454
+ readableRoots?: string[];
455
+ }
456
+
457
+ /**
458
+ * Whether a path token lies outside every root. Each comma-separated part
459
+ * (PowerShell's array syntax, `-Path a,b`; commas inside quotes are part of
460
+ * the name) is judged on its own: refused by
461
+ * shape when unvouchable; a relative part is inside by construction; an
462
+ * absolute part is resolved through `resolveForContainment` — the nearest
463
+ * existing ancestor's realpath, so a glob leaf (`C:\src\play\*.ts`) is
464
+ * judged by its directory and a symlink planted inside the project that
465
+ * points out of it is outside — and must lie inside a root. With no roots
466
+ * (no options) an absolute part is refused, the pre-2026-09-19 behaviour.
467
+ */
468
+ function pathOutsideRoots(value: string, opts: PowerShellReadOnlyOptions | undefined, roots: string[]): boolean {
469
+ for (const part of splitPowerShellList(value)) {
470
+ const trimmed = part.trim();
471
+ if (!trimmed) continue;
472
+ if (pathUnvouchable(trimmed)) return true;
473
+ if (!isAbsoluteSpelling(trimmed)) continue;
474
+ if (!opts) return true;
475
+ // Resolve only what THIS platform's path module calls absolute: on a POSIX
476
+ // host `C:\x` is not, and `toAbsolute` would join it onto the cwd and
477
+ // vouch for a file the shell would never touch. Refuse it by shape there.
478
+ if (!isAbsolute(trimmed)) return true;
479
+ const resolved = resolveForContainment(toAbsolute(opts.cwd, trimmed, opts.home));
480
+ if (resolved === undefined || !roots.some((root) => isWithin(root, resolved))) return true;
481
+ }
482
+ return false;
483
+ }
484
+
485
+ export interface PowerShellReadOnlyVerdict {
486
+ readOnly: boolean;
487
+ /** Why not, for the decision log — never echoes expanded values. */
488
+ reason?: string;
489
+ }
490
+
491
+ /**
492
+ * Whether every statement of the line is a read-only cmdlet (or `where.exe`)
493
+ * with arguments the check can see through: no redirection, no variables or
494
+ * subexpressions, no script blocks, no writing parameters, no path outside
495
+ * the working directory by shape. Positive proof only; anything else is not
496
+ * read-only and takes the ordinary route (classifier in auto mode, a prompt
497
+ * elsewhere).
498
+ */
499
+ export function powershellReadOnly(command: string, opts?: PowerShellReadOnlyOptions): PowerShellReadOnlyVerdict {
500
+ const statements = powershellStatements(command);
501
+ if (statements === undefined) return { readOnly: false, reason: "unbalanced quoting" };
502
+ if (statements.length === 0) return { readOnly: false, reason: "empty command" };
503
+ if (/[<>]/.test(command)) return { readOnly: false, reason: "redirection" };
504
+ if (/\$/.test(command)) return { readOnly: false, reason: "a variable or subexpression" };
505
+ if (/[`{}]/.test(command)) return { readOnly: false, reason: "an escape or script block" };
506
+ // The cwd's realpath once per command (the roots arrive resolved), not once per token.
507
+ const roots = opts ? [resolveForContainment(opts.cwd) ?? opts.cwd, ...(opts.readableRoots ?? [])] : [];
508
+ for (const statement of statements) {
509
+ if (startsWithCallOperator(statement)) return { readOnly: false, reason: "the call operator" };
510
+ const cmd = statementCommand(statement);
511
+ if (!READ_ONLY_POWERSHELL_COMMANDS.has(cmd)) return { readOnly: false, reason: `\`${cmd}\` is not a read-only cmdlet` };
512
+ const tokens = statement.trim().split(/\s+/).slice(1);
513
+ for (const token of tokens) {
514
+ if (WRITING_PARAMETERS.test(token)) return { readOnly: false, reason: `${token.split(":")[0]} writes or forwards` };
515
+ if (token.startsWith("-")) continue;
516
+ // By shape (UNC, ~, PSDrive, ..) a token is never vouched for. An absolute
517
+ // path is read-only only when it resolves inside the working directory or
518
+ // a harness session dir (2026-09-19: /doctor's transcript scan on Windows
519
+ // spelled `<agentDir>\sessions\…` and was classified —
520
+ // docs/decisions/auto-mode.md); a comma list is judged part by part.
521
+ if (pathOutsideRoots(token, opts, roots)) return { readOnly: false, reason: "a path outside the working directory" };
522
+ }
523
+ }
524
+ return { readOnly: true };
525
+ }
526
+
527
+ /** git global flags that take a value and can precede the subcommand (`git -C x status`). */
528
+ const GIT_GLOBAL_VALUE_FLAGS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"]);
529
+
530
+ /**
531
+ * Whether the statement is a bare `git status` (global flags before it and
532
+ * plain flags after it allowed, no other statements) — the call after which
533
+ * Claude Code's classifier transcript carries a
534
+ * `{"meta":{"gitStatus":{"clean":…}}}` ground-truth line (findings §22).
535
+ * Works for bash and PowerShell spellings alike. Global value-taking flags
536
+ * (`-C <dir>`, `-c <key=val>`, …) are skipped so `git -C <dir> status` is
537
+ * still recognised instead of mistaking the flag's value for the subcommand.
538
+ */
539
+ export function isGitStatusCommand(command: string): boolean {
540
+ const statements = powershellStatements(command);
541
+ if (!statements || statements.length !== 1) return false;
542
+ if (statementCommand(statements[0]) !== "git") return false;
543
+ const words = statements[0].trim().split(/\s+/).slice(1);
544
+ let i = 0;
545
+ while (i < words.length && words[i].startsWith("-")) {
546
+ i += GIT_GLOBAL_VALUE_FLAGS.has(words[i]) ? 2 : 1;
547
+ }
548
+ return words[i] === "status" && words.slice(i + 1).every((w) => w.startsWith("-"));
549
+ }
@@ -15,8 +15,9 @@
15
15
  * mode: session allows are never applied there).
16
16
  */
17
17
 
18
- import { dirname } from "node:path";
18
+ import { dirname, resolve } from "node:path";
19
19
  import { toAbsolute } from "../auto-mode/paths.ts";
20
+ import { tildify, toPosixPath } from "../lib/paths.ts";
20
21
  import { escapeLiteral, isAtOrInsideDir, normalizeToolName, parseRule, type PermissionMode, type PermissionRule, subjectKind, urlHost } from "./matcher.ts";
21
22
 
22
23
  export interface SessionGrant {
@@ -40,14 +41,13 @@ export interface SessionGrantInput {
40
41
  home: string;
41
42
  }
42
43
 
43
- /** `~`-abbreviated path for a label. */
44
- function displayPath(path: string, home: string): string {
45
- return path === home ? "~" : path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
46
- }
47
-
48
- /** Claude Code's `//absolute` rule form for a directory and everything under it. */
44
+ /**
45
+ * Claude Code's `//absolute` rule form for a directory and everything under
46
+ * it — on Windows in CC's POSIX spelling (`//c/Users/x/proj/**`), the form
47
+ * `matchesPathPattern` matches native paths as.
48
+ */
49
49
  function absoluteDirPattern(dir: string): string {
50
- return `/${dir.replace(/\/+$/, "")}/**`;
50
+ return `/${toPosixPath(resolve(dir)).replace(/\/+$/, "")}/**`;
51
51
  }
52
52
 
53
53
  export function sessionGrant(input: SessionGrantInput): SessionGrant | undefined {
@@ -78,7 +78,7 @@ export function sessionGrant(input: SessionGrantInput): SessionGrant | undefined
78
78
  return mint(`${tool}(${absoluteDirPattern(cwd)})`, `Yes, and allow ${tool} anywhere in the working directory this session`);
79
79
  }
80
80
  const dir = dirname(absolute);
81
- return mint(`${tool}(${absoluteDirPattern(dir)})`, `Yes, and allow ${tool} under ${displayPath(dir, home)} this session`);
81
+ return mint(`${tool}(${absoluteDirPattern(dir)})`, `Yes, and allow ${tool} under ${tildify(dir, home)} this session`);
82
82
  }
83
83
  case "url": {
84
84
  const host = urlHost(subject);
@@ -65,6 +65,7 @@ import { applyPanelKey, initialPanelState, type PanelEffect, type PanelView } fr
65
65
  import { findShellPlaceholders, replaceShellPlaceholders, substituteArguments } from "./template.ts";
66
66
  import { parseFrontmatterLoosely } from "../lib/frontmatter.ts";
67
67
  import { sessionAlive } from "../lib/session-lifecycle.ts";
68
+ import { registerLocalCommand } from "../lib/local-command.ts";
68
69
 
69
70
  const run = promisify(execFile);
70
71
  const SHELL_TIMEOUT_MS = 30_000;
@@ -518,9 +519,9 @@ export default function pluginsExtension(pi: ExtensionAPI) {
518
519
  }
519
520
  };
520
521
 
521
- pi.registerCommand("plugins", {
522
+ registerLocalCommand(pi, "plugins", {
522
523
  description: "Browse, install, and manage Claude Code-compatible plugins and marketplaces",
523
- handler: async (_args, ctx) => {
524
+ handler: async (args, ctx) => {
524
525
  if (ctx.hasUI) {
525
526
  await openPanel(ctx);
526
527
  return;
@@ -160,7 +160,7 @@ export function uninstallPlugin(root: string, id: string): void {
160
160
  removeInstalledPlugin(root, id);
161
161
  // Only delete what lives inside OUR cache — never an install path that
162
162
  // points elsewhere (hand-edited registries, claude-origin paths).
163
- const cacheDir = `${resolve(join(root, "cache"))}/`;
163
+ const cacheDir = `${resolve(join(root, "cache"))}${sep}`;
164
164
  if (entry?.installPath && resolve(entry.installPath).startsWith(cacheDir)) {
165
165
  rmSync(entry.installPath, { recursive: true, force: true });
166
166
  }