one-code-extension 0.3.0 → 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 +189 -94
- package/docs/guide/README.md +3 -2
- package/docs/guide/bring-your-claude-code-setup.md +7 -5
- package/docs/guide/configuration.md +7 -5
- package/docs/guide/differences-from-claude-code.md +25 -14
- package/docs/guide/doctor.md +5 -3
- package/docs/guide/hooks.md +20 -6
- package/docs/guide/installation.md +62 -12
- package/docs/guide/permissions-modes-and-auto-mode.md +46 -28
- package/docs/guide/providers-and-models.md +12 -10
- package/docs/guide/reference.md +9 -4
- package/docs/guide/sessions-and-context.md +23 -3
- package/docs/guide/skills-plugins-and-mcp.md +7 -5
- package/docs/guide/subagents-and-workflows.md +13 -11
- package/docs/guide/tasks-and-background-work.md +10 -8
- package/docs/guide/terminal-interface.md +7 -5
- package/docs/guide/tools.md +29 -5
- package/docs/guide/troubleshooting.md +26 -14
- 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
|
@@ -8,11 +8,20 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import { isAbsolute, relative, resolve
|
|
11
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
12
12
|
import { analyzeShellCommand, hasInjectionSyntax, leadTokens, parseCommand, resolvePayload } from "../auto-mode/shell-analysis.ts";
|
|
13
|
-
import { pathArgument } from "../auto-mode/paths.ts";
|
|
13
|
+
import { pathArgument, resolveForContainment } from "../auto-mode/paths.ts";
|
|
14
14
|
import { isProtectedPath, isWritingTool } from "./protected-paths.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
canonicalCommandName,
|
|
17
|
+
canonicalizeStatement,
|
|
18
|
+
powershellInjectionSyntax,
|
|
19
|
+
powershellMatchForms,
|
|
20
|
+
powershellReadOnly,
|
|
21
|
+
powershellStatements,
|
|
22
|
+
} from "./powershell-rules.ts";
|
|
23
|
+
import { comparablePath, expandTilde, foldsCase, forwardSlashes, isRelativeInside, toPosixPath } from "../lib/paths.ts";
|
|
24
|
+
import { isShellToolName } from "../lib/shell-tools.ts";
|
|
16
25
|
|
|
17
26
|
export type PermissionMode = "default" | "acceptEdits" | "plan" | "bypassPermissions" | "dontAsk" | "auto";
|
|
18
27
|
/** "classify" is auto mode's outcome: hand the call to the approval classifier. */
|
|
@@ -21,6 +30,7 @@ export type PermissionDecision = "allow" | "deny" | "ask" | "classify";
|
|
|
21
30
|
/** Claude Code tool name (lowercased) → One Code tool name. */
|
|
22
31
|
const CC_TOOL_NAMES: Record<string, string> = {
|
|
23
32
|
bash: "bash",
|
|
33
|
+
powershell: "powershell",
|
|
24
34
|
read: "read",
|
|
25
35
|
edit: "edit",
|
|
26
36
|
write: "write",
|
|
@@ -153,7 +163,7 @@ export function escapeLiteral(command: string): string {
|
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
/** Glob → RegExp. `**` crosses path separators, `*` does not. `\*` / `\\` are literals. */
|
|
156
|
-
function globToRegex(glob: string, pathMode: boolean): RegExp {
|
|
166
|
+
function globToRegex(glob: string, pathMode: boolean, ignoreCase = false): RegExp {
|
|
157
167
|
let out = "";
|
|
158
168
|
for (let i = 0; i < glob.length; i++) {
|
|
159
169
|
const ch = glob[i];
|
|
@@ -174,7 +184,7 @@ function globToRegex(glob: string, pathMode: boolean): RegExp {
|
|
|
174
184
|
out += ch;
|
|
175
185
|
}
|
|
176
186
|
}
|
|
177
|
-
return new RegExp(`^${out}
|
|
187
|
+
return new RegExp(`^${out}$`, ignoreCase ? "i" : "");
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
/**
|
|
@@ -279,13 +289,41 @@ export function bashMatchForms(command: string, depth = 0): string[] {
|
|
|
279
289
|
* `Bash(rm:*)` (CC: "deny/ask rules must match compound commands so they can't
|
|
280
290
|
* be bypassed"). Unparseable lines are matched as a whole only.
|
|
281
291
|
*/
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
292
|
+
/**
|
|
293
|
+
* "Any form matches" with a one-entry memo of the forms: decide() tests every
|
|
294
|
+
* deny rule, then every ask rule, against the same command, and the forms
|
|
295
|
+
* depend on the command alone, so the last parse is kept per shell grammar.
|
|
296
|
+
*/
|
|
297
|
+
function anyFormMatcher(formsOf: (command: string) => string[], matches: (pattern: string, form: string) => boolean) {
|
|
298
|
+
let last: { command: string; forms: string[] } | undefined;
|
|
299
|
+
return (pattern: string, command: string): boolean => {
|
|
300
|
+
if (last?.command !== command) last = { command, forms: formsOf(command) };
|
|
301
|
+
return last.forms.some((form) => matches(pattern, form));
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const bashPatternMatchesAny = anyFormMatcher(bashMatchForms, matchesBashPattern);
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* PowerShell's Bash-pattern match against ONE statement: both sides have their
|
|
309
|
+
* command word alias-canonicalized (`ls` ≡ `Get-ChildItem`) and are compared
|
|
310
|
+
* case-insensitively (cmdlet names are), then Claude Code's Bash pattern rules
|
|
311
|
+
* apply unchanged — exact, `prefix:*`, wildcards (powershell-rules.ts).
|
|
312
|
+
*/
|
|
313
|
+
export function matchesPowerShellPattern(pattern: string, statement: string): boolean {
|
|
314
|
+
const canonicalPattern = pattern.endsWith(":*")
|
|
315
|
+
? `${canonicalizeStatement(pattern.slice(0, -2))}:*`
|
|
316
|
+
: canonicalizeStatement(pattern);
|
|
317
|
+
return matchesBashPattern(canonicalPattern.toLowerCase(), canonicalizeStatement(statement).toLowerCase());
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Deny/ask semantics for PowerShell: the pattern covers the line, any statement, or any canonical/nested form. */
|
|
321
|
+
const powershellPatternMatchesAny = anyFormMatcher(powershellMatchForms, matchesPowerShellPattern);
|
|
322
|
+
|
|
323
|
+
/** The tools whose subject is a shell command line and whose rules follow the Bash shape (lib/shell-tools.ts). */
|
|
324
|
+
export function isShellTool(toolName: string): boolean {
|
|
325
|
+
return isShellToolName(normalizeToolName(toolName));
|
|
287
326
|
}
|
|
288
|
-
let lastForms: { command: string; forms: string[] } | undefined;
|
|
289
327
|
|
|
290
328
|
/**
|
|
291
329
|
* The allow rule that lets a bash command run, or undefined. CC semantics: an
|
|
@@ -312,12 +350,17 @@ export function findBashAllowRule(rules: PermissionRule[], command: string, tool
|
|
|
312
350
|
(r) => r.pattern !== undefined && !hasUnescapedWildcard(r.pattern) && unescapeLiteral(r.pattern) === cmd,
|
|
313
351
|
);
|
|
314
352
|
if (exact) return exact;
|
|
315
|
-
|
|
316
|
-
|
|
353
|
+
// PowerShell lines split on PowerShell's separators and match alias-
|
|
354
|
+
// canonicalized, case-insensitively (powershell-rules.ts); the shape of
|
|
355
|
+
// the rule — every statement covered, no injection syntax — is the same.
|
|
356
|
+
const powershell = normalizeToolName(toolName) === "powershell";
|
|
357
|
+
if (powershell ? powershellInjectionSyntax(cmd) : hasInjectionSyntax(cmd)) return undefined;
|
|
358
|
+
const subs = powershell ? powershellStatements(cmd) : bashSubcommands(cmd);
|
|
317
359
|
if (!subs || subs.length === 0) return undefined;
|
|
360
|
+
const matches = powershell ? matchesPowerShellPattern : matchesBashPattern;
|
|
318
361
|
let first: PermissionRule | undefined;
|
|
319
362
|
for (const sub of subs) {
|
|
320
|
-
const rule = bashRules.find((r) => r.pattern !== undefined &&
|
|
363
|
+
const rule = bashRules.find((r) => r.pattern !== undefined && matches(r.pattern, sub));
|
|
321
364
|
if (!rule) return undefined;
|
|
322
365
|
first ??= rule;
|
|
323
366
|
}
|
|
@@ -329,23 +372,44 @@ export function findBashAllowRule(rules: PermissionRule[], command: string, tool
|
|
|
329
372
|
* (with `.`/`..` normalised), cwd-relative, and `~/`-relative — never the raw
|
|
330
373
|
* spelling: `Edit(docs/**)` must not match `docs/../src/main.ts`, and a
|
|
331
374
|
* `~/.ssh/id_rsa` subject must hit `Read(~/.ssh/**)` (review P6).
|
|
375
|
+
*
|
|
376
|
+
* Every candidate is spelled with forward slashes: patterns are `/`-globs
|
|
377
|
+
* (gitignore semantics, as in Claude Code), so on Windows the native
|
|
378
|
+
* `C:\Users\x\docs\a.md` is matched as Claude Code matches it — its POSIX form
|
|
379
|
+
* `/c/Users/x/docs/a.md` (`lib/paths.ts toPosixPath`, the shape CC's own
|
|
380
|
+
* Windows rules such as `Read(//c/Users/x/**)` are written in) — and also as
|
|
381
|
+
* `C:/Users/x/docs/a.md`, so a rule spelled with the drive letter works too.
|
|
382
|
+
* darwin and win32 compare case-insensitively, like their filesystems.
|
|
332
383
|
*/
|
|
333
384
|
export function matchesPathPattern(pattern: string, subject: string, cwd: string): boolean {
|
|
334
385
|
const home = homedir();
|
|
386
|
+
const win32 = process.platform === "win32";
|
|
335
387
|
// Claude Code's rule syntax: `//path` is an absolute filesystem path (the
|
|
336
388
|
// doubled slash distinguishes it from `/path`, which CC reads relative to
|
|
337
389
|
// the project root). `Read(//etc/**)` is the form CC's own suggestions write.
|
|
338
|
-
|
|
390
|
+
// A Windows pattern spelled with backslash separators is read as its `/`
|
|
391
|
+
// form; `\*` and `\\` stay the literal escapes globToRegex documents.
|
|
392
|
+
const spelled = win32 ? pattern.replace(/\\(?![*\\])/g, "/") : pattern;
|
|
393
|
+
// `~` in a pattern expands to the home dir spelled with `/` (never `join`,
|
|
394
|
+
// whose backslashes would read as escapes on Windows); it matches the
|
|
395
|
+
// `C:/Users/x/…` candidate below.
|
|
396
|
+
const expandHome = (p: string) => (p === "~" ? forwardSlashes(home) : p.startsWith("~/") ? `${forwardSlashes(home)}/${p.slice(2)}` : p);
|
|
397
|
+
const expandedPattern = expandHome(spelled.startsWith("//") ? spelled.slice(1) : spelled);
|
|
339
398
|
const expandedSubject = expandTilde(subject, home);
|
|
340
399
|
|
|
341
400
|
const candidates = new Set<string>();
|
|
342
401
|
const absolute = isAbsolute(expandedSubject) ? resolve(expandedSubject) : resolve(cwd, expandedSubject);
|
|
343
|
-
candidates.add(absolute);
|
|
402
|
+
candidates.add(toPosixPath(absolute));
|
|
403
|
+
if (win32) candidates.add(forwardSlashes(absolute));
|
|
344
404
|
const rel = relative(cwd, absolute);
|
|
345
|
-
if (rel
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
405
|
+
if (isRelativeInside(rel)) candidates.add(forwardSlashes(rel));
|
|
406
|
+
const homeRel = relative(resolve(home), absolute);
|
|
407
|
+
if (isRelativeInside(homeRel)) candidates.add(`~/${forwardSlashes(homeRel)}`);
|
|
408
|
+
|
|
409
|
+
// Case-insensitive where the filesystem is (darwin, win32): `Read(~/Notes/**)`
|
|
410
|
+
// covers `~/notes/a.md`, the same file there — the rule every other
|
|
411
|
+
// containment check in this file compares by (comparablePath).
|
|
412
|
+
const regex = globToRegex(expandedPattern, true, foldsCase());
|
|
349
413
|
for (const candidate of candidates) {
|
|
350
414
|
if (regex.test(candidate)) return true;
|
|
351
415
|
}
|
|
@@ -362,7 +426,7 @@ export type SubjectKind = "command" | "path" | "url" | "text";
|
|
|
362
426
|
|
|
363
427
|
export function subjectKind(toolName: string): SubjectKind {
|
|
364
428
|
const name = normalizeToolName(toolName);
|
|
365
|
-
if (name
|
|
429
|
+
if (isShellToolName(name) || name === "monitor") return "command";
|
|
366
430
|
if (name === "web_fetch") return "url";
|
|
367
431
|
if (isPathSubjectTool(name)) return "path";
|
|
368
432
|
return "text";
|
|
@@ -391,6 +455,7 @@ export function extractSubject(toolName: string, input: Record<string, unknown>)
|
|
|
391
455
|
const str = (key: string): string | undefined => (typeof input[key] === "string" ? (input[key] as string) : undefined);
|
|
392
456
|
switch (name) {
|
|
393
457
|
case "bash":
|
|
458
|
+
case "powershell":
|
|
394
459
|
case "monitor":
|
|
395
460
|
return str("command") ?? "";
|
|
396
461
|
case "web_fetch":
|
|
@@ -444,8 +509,7 @@ function matchesUrlPattern(pattern: string, url: string): boolean {
|
|
|
444
509
|
* `/home/u/project`.
|
|
445
510
|
*/
|
|
446
511
|
function toAbsoluteFolded(p: string, cwd: string): string {
|
|
447
|
-
|
|
448
|
-
return process.platform === "linux" ? absolute : absolute.toLowerCase();
|
|
512
|
+
return comparablePath(resolve(cwd, expandTilde(p, homedir())));
|
|
449
513
|
}
|
|
450
514
|
|
|
451
515
|
function isPlanFilePath(candidate: string, planFilePath: string, cwd: string): boolean {
|
|
@@ -460,7 +524,7 @@ function isPlanFilePath(candidate: string, planFilePath: string, cwd: string): b
|
|
|
460
524
|
* case-folded from `resolveForContainment`.
|
|
461
525
|
*/
|
|
462
526
|
export function isInsideDir(candidate: string, dir: string, cwd: string): boolean {
|
|
463
|
-
return toAbsoluteFolded(candidate, cwd).startsWith(toAbsoluteFolded(dir, cwd)
|
|
527
|
+
return toAbsoluteFolded(candidate, cwd).startsWith(`${toAbsoluteFolded(dir, cwd)}/`);
|
|
464
528
|
}
|
|
465
529
|
|
|
466
530
|
/** `isInsideDir`, plus the directory itself (`ls <cwd>` lists the working directory). */
|
|
@@ -478,7 +542,9 @@ export function ruleMatches(rule: PermissionRule, toolName: string, subject: str
|
|
|
478
542
|
if (!subject) return false;
|
|
479
543
|
switch (subjectKind(toolName)) {
|
|
480
544
|
case "command":
|
|
481
|
-
return
|
|
545
|
+
return normalizeToolName(toolName) === "powershell"
|
|
546
|
+
? powershellPatternMatchesAny(rule.pattern, subject)
|
|
547
|
+
: bashPatternMatchesAny(rule.pattern, subject);
|
|
482
548
|
case "path":
|
|
483
549
|
return matchesPathPattern(rule.pattern, subject, cwd);
|
|
484
550
|
case "url":
|
|
@@ -514,7 +580,7 @@ export function toolTier(toolName: string): ToolTier {
|
|
|
514
580
|
const name = normalizeToolName(toolName);
|
|
515
581
|
if (SAFE_TOOLS.has(name)) return "safe";
|
|
516
582
|
if (EDIT_TOOLS.has(name)) return "edit";
|
|
517
|
-
if (name
|
|
583
|
+
if (isShellToolName(name)) return "execute";
|
|
518
584
|
return "custom";
|
|
519
585
|
}
|
|
520
586
|
|
|
@@ -625,6 +691,17 @@ export interface DecideInput {
|
|
|
625
691
|
* told to read those files back.
|
|
626
692
|
*/
|
|
627
693
|
resultsDirPath?: string;
|
|
694
|
+
/**
|
|
695
|
+
* This project's pi session directory (`<agentDir>/sessions/<encoded-cwd>`):
|
|
696
|
+
* the session transcripts and, next to them, auto mode's decision log.
|
|
697
|
+
* Readable like the working directory — Claude Code lets a session read
|
|
698
|
+
* its own project directory under its config root, which is where its
|
|
699
|
+
* transcripts live (`checkReadableInternalPath`, "Project directory files
|
|
700
|
+
* are allowed for reading"), and /doctor's evidence gathering depends on
|
|
701
|
+
* it. Other projects' session directories are not in the set. Never a
|
|
702
|
+
* write root: the agent dir stays protected.
|
|
703
|
+
*/
|
|
704
|
+
sessionDirPath?: string;
|
|
628
705
|
/**
|
|
629
706
|
* Directories protected at runtime, beyond the static list in
|
|
630
707
|
* protected-paths.ts (see `runtimeProtectedDirs()`, the sole producer): pi's
|
|
@@ -684,7 +761,7 @@ const DELEGATION_TOOLS = new Set(["Agent", "workflow", "SendMessage"]);
|
|
|
684
761
|
* surely as `Bash(*)` does.
|
|
685
762
|
*/
|
|
686
763
|
const INTERPRETERS_AND_RUNNERS =
|
|
687
|
-
/^(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/;
|
|
764
|
+
/^(python[0-9.]*|python3|node|deno|bun|ruby|perl|php|osascript|bash|sh|zsh|fish|pwsh|powershell|cmd|wsl|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|invoke-expression|invoke-command|start-process|start-job)\b/;
|
|
688
765
|
|
|
689
766
|
/**
|
|
690
767
|
* An allow rule broad enough to grant arbitrary code execution. Auto mode
|
|
@@ -700,7 +777,7 @@ export function isBroadExecutionRule(rule: PermissionRule): boolean {
|
|
|
700
777
|
// Delegation rules are dropped outright: a subagent is a fresh agent loop, so
|
|
701
778
|
// pre-approving one pre-approves whatever that loop decides to do.
|
|
702
779
|
if (DELEGATION_TOOLS.has(rule.tool)) return true;
|
|
703
|
-
if (rule.tool
|
|
780
|
+
if (!isShellToolName(rule.tool)) return false;
|
|
704
781
|
|
|
705
782
|
if (!rule.pattern) return true;
|
|
706
783
|
const pattern = rule.pattern.trim().toLowerCase();
|
|
@@ -710,10 +787,13 @@ export function isBroadExecutionRule(rule: PermissionRule): boolean {
|
|
|
710
787
|
if (!hasUnescapedWildcard(pattern)) return false;
|
|
711
788
|
if (/^(\*|:\*|\*\*)$/.test(pattern)) return true;
|
|
712
789
|
|
|
713
|
-
const
|
|
790
|
+
const rawHead = pattern.split(/[\s*]/)[0] ?? "";
|
|
791
|
+
// A PowerShell alias is as broad as its cmdlet: `PowerShell(iex *)` is
|
|
792
|
+
// `Invoke-Expression *`, `PowerShell(saps *)` is `Start-Process *`.
|
|
793
|
+
const head = rule.tool === "powershell" ? canonicalCommandName(rawHead).toLowerCase() : rawHead;
|
|
714
794
|
if (!INTERPRETERS_AND_RUNNERS.test(head)) return false;
|
|
715
795
|
|
|
716
|
-
const rest = pattern.slice(
|
|
796
|
+
const rest = pattern.slice(rawHead.length).trim();
|
|
717
797
|
// Nothing constrains the arguments: `python*`, `node *`, `npm *`.
|
|
718
798
|
if (/^[:\s]*\*+$/.test(rest)) return true;
|
|
719
799
|
// The runner's own escape hatch takes arbitrary code: `npm run *`, `npx *`.
|
|
@@ -762,14 +842,21 @@ export function decide(params: DecideInput): Decision {
|
|
|
762
842
|
// redirect writes, which it records; a safe verdict with no writes is
|
|
763
843
|
// exactly read-only. Anything it cannot vouch for is denied as before.
|
|
764
844
|
// Without this the frontier tier (no grep/find/ls) was left with `read` alone.
|
|
845
|
+
// The harness's readable session dirs, as the auto-mode pre-gate sees them
|
|
846
|
+
// (permissions/index.ts `readableRoots`): a plan-mode read of this project's
|
|
847
|
+
// transcripts is a read like any other.
|
|
848
|
+
const readableRoots = [params.memoryDirPath, params.scratchpadDirPath, params.resultsDirPath, params.sessionDirPath].filter((d): d is string => !!d).map((d) => resolveForContainment(d) ?? d);
|
|
765
849
|
if (tool === "bash" && subject) {
|
|
766
|
-
const evidence = analyzeShellCommand({ command: subject, cwd, home: homedir(), protectedDirs: params.protectedDirs });
|
|
850
|
+
const evidence = analyzeShellCommand({ command: subject, cwd, home: homedir(), protectedDirs: params.protectedDirs, readableRoots });
|
|
767
851
|
if (evidence.verdict === "safe" && evidence.writes.length === 0) return { decision: "allow", cause: "plan-readonly" };
|
|
768
852
|
// Read-only, but of a path outside the working directory: still a read,
|
|
769
853
|
// so it is put to the user rather than refused as a plan-mode mutation
|
|
770
854
|
// (the read tools ask for the same path below).
|
|
771
855
|
if (evidence.readOnlyOutside && evidence.writes.length === 0) return { decision: "ask", cause: "working-dir" };
|
|
772
856
|
}
|
|
857
|
+
// The PowerShell counterpart: Claude Code's read-only cmdlet allowlist,
|
|
858
|
+
// with in-project paths by shape (powershell-rules.ts).
|
|
859
|
+
if (tool === "powershell" && subject && powershellReadOnly(subject, { cwd, home: homedir(), readableRoots }).readOnly) return { decision: "allow", cause: "plan-readonly" };
|
|
773
860
|
if (PLAN_READ_ONLY_TOOLS.has(tool)) return { decision: "allow", cause: "plan-readonly" };
|
|
774
861
|
return { decision: "deny", cause: "plan-mode" };
|
|
775
862
|
}
|
|
@@ -825,7 +912,7 @@ export function decide(params: DecideInput): Decision {
|
|
|
825
912
|
mode === "auto"
|
|
826
913
|
? allow.filter((rule) => {
|
|
827
914
|
if (isBroadExecutionRule(rule)) return false;
|
|
828
|
-
if (params.classifyAllShell && rule.tool
|
|
915
|
+
if (params.classifyAllShell && isShellTool(rule.tool)) return false;
|
|
829
916
|
return true;
|
|
830
917
|
})
|
|
831
918
|
: allow;
|
|
@@ -857,7 +944,7 @@ export function decide(params: DecideInput): Decision {
|
|
|
857
944
|
*/
|
|
858
945
|
const inWorkingSpace = (): boolean => {
|
|
859
946
|
const target = params.resolvedSubject ?? subject;
|
|
860
|
-
const roots = [cwd, params.resolvedCwd, params.memoryDirPath, params.scratchpadDirPath, params.resultsDirPath];
|
|
947
|
+
const roots = [cwd, params.resolvedCwd, params.memoryDirPath, params.scratchpadDirPath, params.resultsDirPath, params.sessionDirPath];
|
|
861
948
|
if (roots.some((dir) => dir && isAtOrInsideDir(target, dir, cwd))) return true;
|
|
862
949
|
return params.planFilePath ? isPlanFilePath(target, params.planFilePath, cwd) : false;
|
|
863
950
|
};
|
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { PermissionMode } from "./matcher.ts";
|
|
13
|
+
import { modeCycleKey, pauseGlyph } from "../lib/keys.ts";
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Footer badge per mode — Claude Code's exact strings and icons. The pause
|
|
17
|
+
* icon is swapped for a single-cell glyph on Windows and WSL, where Windows
|
|
18
|
+
* Terminal draws U+23F8 two cells wide and over the mode name (lib/keys.ts).
|
|
19
|
+
*/
|
|
20
|
+
const PAUSE = pauseGlyph();
|
|
15
21
|
export const MODE_BADGES: Record<PermissionMode, string> = {
|
|
16
|
-
default:
|
|
22
|
+
default: `${PAUSE} manual mode on`,
|
|
17
23
|
acceptEdits: "⏵⏵ accept edits on",
|
|
18
|
-
plan:
|
|
24
|
+
plan: `${PAUSE} plan mode on`,
|
|
19
25
|
bypassPermissions: "⏵⏵ bypass permissions on",
|
|
20
26
|
dontAsk: "⏵⏵ don't ask on",
|
|
21
27
|
auto: "⏵⏵ auto mode on",
|
|
@@ -86,9 +92,11 @@ export function isRealModel(id: string | undefined): id is string {
|
|
|
86
92
|
/**
|
|
87
93
|
* The key that cycles permission modes. Claude Code cycles on shift+tab and
|
|
88
94
|
* says so in this footer; pi reserves shift+tab for the effort dial, so One Code
|
|
89
|
-
* cycles on ctrl+q (see docs/decisions/modes.md)
|
|
95
|
+
* cycles on ctrl+q (see docs/decisions/modes.md) — alt+m on Windows and WSL,
|
|
96
|
+
* where pi owns ctrl+q (lib/keys.ts) — and the hint names that key.
|
|
90
97
|
*/
|
|
91
|
-
export const
|
|
98
|
+
export const CYCLE_KEY = modeCycleKey();
|
|
99
|
+
export const CYCLE_KEY_HINT = `${CYCLE_KEY} to cycle`;
|
|
92
100
|
|
|
93
101
|
/**
|
|
94
102
|
* The footer badge, with auto mode naming the model screening the calls. That
|
|
@@ -110,7 +118,7 @@ export function modeBadge(
|
|
|
110
118
|
function badgeBase(mode: PermissionMode, opts?: { paused?: boolean; classifierModel?: string }): string {
|
|
111
119
|
if (mode !== "auto") return MODE_BADGES[mode];
|
|
112
120
|
const suffix = opts?.classifierModel ? ` · ${shortModelName(opts.classifierModel)}` : "";
|
|
113
|
-
return opts?.paused ?
|
|
121
|
+
return opts?.paused ? `${PAUSE} auto mode paused${suffix}` : `${MODE_BADGES.auto}${suffix}`;
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
/**
|