one-code-extension 0.3.1 → 0.4.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/README.md +88 -10
- package/docs/guide/README.md +1 -0
- package/docs/guide/configuration.md +2 -2
- package/docs/guide/differences-from-claude-code.md +13 -4
- package/docs/guide/hooks.md +14 -2
- package/docs/guide/installation.md +54 -6
- package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
- package/docs/guide/reference.md +5 -2
- package/docs/guide/sessions-and-context.md +18 -0
- package/docs/guide/terminal-interface.md +1 -1
- package/docs/guide/tools.md +24 -2
- package/docs/guide/troubleshooting.md +14 -4
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- 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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
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 ${
|
|
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
|
|
522
|
+
registerLocalCommand(pi, "plugins", {
|
|
522
523
|
description: "Browse, install, and manage Claude Code-compatible plugins and marketplaces",
|
|
523
|
-
handler: async (
|
|
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
|
}
|