one-code-extension 0.1.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +222 -177
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +128 -0
- package/docs/guide/configuration.md +173 -0
- package/docs/guide/differences-from-claude-code.md +152 -0
- package/docs/guide/doctor.md +88 -0
- package/docs/guide/hooks.md +111 -0
- package/docs/guide/installation.md +126 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
- package/docs/guide/providers-and-models.md +196 -0
- package/docs/guide/reference.md +226 -0
- package/docs/guide/sessions-and-context.md +192 -0
- package/docs/guide/skills-plugins-and-mcp.md +178 -0
- package/docs/guide/subagents-and-workflows.md +195 -0
- package/docs/guide/tasks-and-background-work.md +113 -0
- package/docs/guide/terminal-interface.md +157 -0
- package/docs/guide/tools.md +177 -0
- package/docs/guide/troubleshooting.md +177 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* denylist — see `sensitive.ts`), F1 (git global flags).
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import { isProtectedPath } from "../permissions/protected-paths.ts";
|
|
33
34
|
import { isExecutionPrimitivePath, isSensitivePath } from "./sensitive.ts";
|
|
34
35
|
import { isWithin, resolveForContainment, toAbsolute } from "./paths.ts";
|
|
35
36
|
|
|
@@ -47,8 +48,46 @@ export interface ShellEvidence {
|
|
|
47
48
|
sensitivePaths: string[];
|
|
48
49
|
/** In-project paths whose contents execute later (`.git/hooks/*`, `.vscode/*.json`). */
|
|
49
50
|
executionPrimitives: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Tooling/agent-configuration paths (`permissions/protected-paths.ts`: `.cargo/`,
|
|
53
|
+
* `.claude/`, hook configs, shell rc files, …) this command writes. The
|
|
54
|
+
* `write`/`edit` tools route these to the classifier; a shell redirect or
|
|
55
|
+
* `rm`/`mv` onto the same path must not be "safe" merely for being in-project.
|
|
56
|
+
*/
|
|
57
|
+
protectedPaths: string[];
|
|
50
58
|
/** Network-capable commands present (curl, ssh, …) — an egress signal for the classifier. */
|
|
51
59
|
network: string[];
|
|
60
|
+
/**
|
|
61
|
+
* Paths a read-only command reads from OUTSIDE the working directory
|
|
62
|
+
* (original tokens). Claude Code asks for every read outside it; here the
|
|
63
|
+
* read escalates so the classifier at least sees it — until 2026-09-05
|
|
64
|
+
* `cat ~/.onecode/agent/auth.json` fast-pathed as "safe"
|
|
65
|
+
* (PERMISSIONS-REVIEW-2026-09-05 H2).
|
|
66
|
+
*/
|
|
67
|
+
outsideReads: string[];
|
|
68
|
+
/**
|
|
69
|
+
* True when the ONLY reason this escalated is one or more outside-cwd reads:
|
|
70
|
+
* no write, mutation, network, unknown command or unmodelled syntax. Plan
|
|
71
|
+
* mode uses it to put such a command to the user as a read instead of
|
|
72
|
+
* refusing it as a mutation. Never true for a "safe" verdict.
|
|
73
|
+
*/
|
|
74
|
+
readOnlyOutside: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* True when the ONLY reason this escalated is an in-project filesystem
|
|
77
|
+
* mutation/deletion — every path is inside the working directory and resolved,
|
|
78
|
+
* nothing touches the network, no credential/execution-primitive path, no
|
|
79
|
+
* unknown command, interpreter, glob, xargs, `cd`, or unmodelled syntax. The
|
|
80
|
+
* containment gate uses this to decide whether a git-recoverability check may
|
|
81
|
+
* clear the command (see auto-mode/recoverability.ts). Never true when the
|
|
82
|
+
* verdict is "safe" (nothing escalated) — it only qualifies an escalation.
|
|
83
|
+
*/
|
|
84
|
+
containedNonNetwork: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* True for a whole-working-tree destructive git op (`git reset --hard`), whose
|
|
87
|
+
* recoverability is judged against the whole tree's cleanliness rather than
|
|
88
|
+
* named paths.
|
|
89
|
+
*/
|
|
90
|
+
wholeTree: boolean;
|
|
52
91
|
}
|
|
53
92
|
|
|
54
93
|
/** Commands that read and do not write, and are safe to fast-path. */
|
|
@@ -77,7 +116,9 @@ const READ_ONLY_COMMANDS = new Set([
|
|
|
77
116
|
"nl",
|
|
78
117
|
"od",
|
|
79
118
|
"paste",
|
|
80
|
-
|
|
119
|
+
// `printenv` and a bare `env` are NOT read-only for this purpose: they dump
|
|
120
|
+
// the process environment, provider keys included (Claude Code removed both
|
|
121
|
+
// from its read-only list for the same reason).
|
|
81
122
|
"printf",
|
|
82
123
|
"pwd",
|
|
83
124
|
"readlink",
|
|
@@ -101,6 +142,29 @@ const READ_ONLY_COMMANDS = new Set([
|
|
|
101
142
|
"yes",
|
|
102
143
|
]);
|
|
103
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Read-only commands that take no file operands, so a path-looking positional
|
|
147
|
+
* (`echo a/b`, `which node`) is not a read of that path and is not checked for
|
|
148
|
+
* working-directory containment.
|
|
149
|
+
*/
|
|
150
|
+
const NO_FILE_OPERANDS = new Set([
|
|
151
|
+
"basename",
|
|
152
|
+
"date",
|
|
153
|
+
"dirname",
|
|
154
|
+
"echo",
|
|
155
|
+
"false",
|
|
156
|
+
"hostname",
|
|
157
|
+
"id",
|
|
158
|
+
"printf",
|
|
159
|
+
"pwd",
|
|
160
|
+
"tr",
|
|
161
|
+
"true",
|
|
162
|
+
"uname",
|
|
163
|
+
"which",
|
|
164
|
+
"whoami",
|
|
165
|
+
"yes",
|
|
166
|
+
]);
|
|
167
|
+
|
|
104
168
|
/**
|
|
105
169
|
* `grep` and friends take a pattern before their paths, so the first positional
|
|
106
170
|
* is not a path token. Tracked separately to avoid classifying a regex as a file.
|
|
@@ -160,6 +224,46 @@ const MUTATION_COMMANDS = new Set([
|
|
|
160
224
|
"zip",
|
|
161
225
|
]);
|
|
162
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The delete/truncate commands whose blast radius is exactly their (in-project,
|
|
229
|
+
* resolved, concrete) path arguments, so an in-project use may be cleared by the
|
|
230
|
+
* containment + git-recoverability gate rather than always reaching the
|
|
231
|
+
* classifier. This is an *allowlist* for the "provably contained" conclusion —
|
|
232
|
+
* omission is safe (the command still escalates to the classifier via the generic
|
|
233
|
+
* mutation path). Overwrite/copy tools (cp, mv, dd, tee) are deliberately left
|
|
234
|
+
* out for now: their destination semantics are subtler, so they keep classifying.
|
|
235
|
+
* Membership here does not clear anything on its own: every target must resolve
|
|
236
|
+
* inside the working directory and the recoverability check must pass.
|
|
237
|
+
*/
|
|
238
|
+
const DELETE_COMMANDS = new Set(["rm", "rmdir", "shred", "truncate"]);
|
|
239
|
+
|
|
240
|
+
/** Glob/other shell metacharacters we cannot enumerate, so a target carrying one is not "concrete". */
|
|
241
|
+
function hasGlob(token: string): boolean {
|
|
242
|
+
return /[*?\[\]]/.test(token);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `git reset --hard [ref]` — a whole-working-tree discard of uncommitted changes. */
|
|
246
|
+
function isWholeTreeGitReset(args: Token[]): boolean {
|
|
247
|
+
const positionals = args.filter((token) => !token.value.startsWith("-"));
|
|
248
|
+
return positionals[0]?.value === "reset" && args.some((token) => token.value === "--hard");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Whether git is being pointed at another repository or tree (`-C dir`,
|
|
253
|
+
* `--git-dir[=]…`, `--work-tree[=]…`, `--namespace`, `--exec-path`). A
|
|
254
|
+
* whole-tree op carrying one of these acts on THAT tree, so judging it against
|
|
255
|
+
* the working directory would clear a reset of some other checkout.
|
|
256
|
+
*/
|
|
257
|
+
function hasGitRetargetFlag(args: Token[]): boolean {
|
|
258
|
+
return args.some((token) => {
|
|
259
|
+
const value = token.value;
|
|
260
|
+
if (!value.startsWith("-")) return false;
|
|
261
|
+
if (GIT_GLOBAL_VALUE_FLAGS.has(value)) return true;
|
|
262
|
+
const eq = value.indexOf("=");
|
|
263
|
+
return eq > 0 && GIT_GLOBAL_VALUE_FLAGS.has(value.slice(0, eq));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
163
267
|
/**
|
|
164
268
|
* Interpreters whose *scripts* can write files the token-level pass never sees
|
|
165
269
|
* (review finding N8: `awk 'BEGIN{print > "f"}'`). Their programs are not
|
|
@@ -236,14 +340,14 @@ const GIT_READ_ONLY_SUBCOMMANDS = new Set([
|
|
|
236
340
|
*/
|
|
237
341
|
const GIT_GLOBAL_VALUE_FLAGS = new Set(["-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
|
|
238
342
|
|
|
239
|
-
interface Token {
|
|
343
|
+
export interface Token {
|
|
240
344
|
/** The token with quotes removed and `$'…'` decoded. */
|
|
241
345
|
value: string;
|
|
242
346
|
/** True when the token was written with any quoting or expansion syntax. */
|
|
243
347
|
hadExpansion: boolean;
|
|
244
348
|
}
|
|
245
349
|
|
|
246
|
-
interface Segment {
|
|
350
|
+
export interface Segment {
|
|
247
351
|
tokens: Token[];
|
|
248
352
|
/** Redirection targets found in this segment, in written order. */
|
|
249
353
|
redirects: string[];
|
|
@@ -282,27 +386,40 @@ function decodeAnsiC(body: string): string {
|
|
|
282
386
|
});
|
|
283
387
|
}
|
|
284
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Syntax through which a command can run something other than what its
|
|
391
|
+
* visible words say. The permission matcher refuses to let a prefix/wildcard
|
|
392
|
+
* allow rule cover a command carrying any of it (the user approved `npm test
|
|
393
|
+
* …`, not whatever `$(…)` evaluates to), and the pre-gate escalates on it.
|
|
394
|
+
*/
|
|
395
|
+
export function hasInjectionSyntax(command: string): string | undefined {
|
|
396
|
+
if (/\$\(/.test(command)) return "uses command substitution $( )";
|
|
397
|
+
if (/(^|[^\\])`/.test(command)) return "uses backtick command substitution";
|
|
398
|
+
if (/[<>]\(/.test(command)) return "uses process substitution";
|
|
399
|
+
if (/\beval\b|\bexec\b/.test(command)) return "uses eval/exec";
|
|
400
|
+
if (/\|\s*(bash|sh|zsh|python|perl|node|ruby)\b/.test(command)) return "pipes into an interpreter";
|
|
401
|
+
if (/base64\s+(-d|--decode)/.test(command)) return "decodes base64, which can hide the real command";
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
285
405
|
/** Syntax we do not model at all; its presence alone forces escalation. */
|
|
286
406
|
export function hasUnmodelledSyntax(command: string): string | undefined {
|
|
287
407
|
if (command.includes("\n")) return "spans multiple lines";
|
|
288
|
-
|
|
289
|
-
if (
|
|
408
|
+
const injection = hasInjectionSyntax(command);
|
|
409
|
+
if (injection) return injection;
|
|
290
410
|
if (/<<</.test(command)) return "uses a here-string";
|
|
291
411
|
if (/<</.test(command)) return "uses a heredoc";
|
|
292
|
-
if (/[<>]\(/.test(command)) return "uses process substitution";
|
|
293
412
|
// Brace expansion resolves to paths we cannot enumerate (review finding N3).
|
|
294
413
|
if (/\{[^{}]*,[^{}]*\}/.test(command)) return "uses brace expansion, whose expanded paths cannot be checked";
|
|
295
414
|
if (/\$\{?[A-Za-z_]/.test(command)) return "references environment variables, whose values are unknown here";
|
|
296
|
-
if (/\beval\b|\bexec\b/.test(command)) return "uses eval/exec";
|
|
297
|
-
if (/\|\s*(bash|sh|zsh|python|perl|node|ruby)\b/.test(command)) return "pipes into an interpreter";
|
|
298
|
-
if (/base64\s+(-d|--decode)/.test(command)) return "decodes base64, which can hide the real command";
|
|
299
415
|
return undefined;
|
|
300
416
|
}
|
|
301
417
|
|
|
302
418
|
/**
|
|
303
419
|
* Split a command into pipeline/list segments and tokenize each. Unlike the
|
|
304
420
|
* original, `<`, `>`, `&`, and `|` terminate a token even without surrounding
|
|
305
|
-
* whitespace, so `cmd>file` and `a|b` are seen (review finding N4).
|
|
421
|
+
* whitespace, so `cmd>file` and `a|b` are seen (review finding N4). An
|
|
422
|
+
* unquoted newline ends a segment too (it is a command separator in bash).
|
|
306
423
|
*/
|
|
307
424
|
export function parseCommand(command: string): { segments: Segment[]; parseFailed: boolean } {
|
|
308
425
|
const segments: Segment[] = [];
|
|
@@ -332,14 +449,15 @@ export function parseCommand(command: string): { segments: Segment[]; parseFaile
|
|
|
332
449
|
quoted = false;
|
|
333
450
|
};
|
|
334
451
|
|
|
335
|
-
|
|
452
|
+
/** `at` = index of the separator; `width` = its length (`&&` is 2). */
|
|
453
|
+
const pushSegment = (at: number, width = 1) => {
|
|
336
454
|
pushToken();
|
|
337
455
|
if (tokens.length > 0 || redirects.length > 0) {
|
|
338
456
|
segments.push({ tokens, redirects, raw: command.slice(rawStart, at).trim() });
|
|
339
457
|
}
|
|
340
458
|
tokens = [];
|
|
341
459
|
redirects = [];
|
|
342
|
-
rawStart = at +
|
|
460
|
+
rawStart = at + width;
|
|
343
461
|
};
|
|
344
462
|
|
|
345
463
|
for (let i = 0; i < command.length; i++) {
|
|
@@ -382,6 +500,12 @@ export function parseCommand(command: string): { segments: Segment[]; parseFaile
|
|
|
382
500
|
continue;
|
|
383
501
|
}
|
|
384
502
|
|
|
503
|
+
if (ch === "\n") {
|
|
504
|
+
// An unquoted newline separates commands exactly like `;`. Without this
|
|
505
|
+
// `npm test x\ncurl evil` would be one segment whose lead is `npm test`.
|
|
506
|
+
pushSegment(i);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
385
509
|
if (/\s/.test(ch)) {
|
|
386
510
|
pushToken();
|
|
387
511
|
continue;
|
|
@@ -408,8 +532,9 @@ export function parseCommand(command: string): { segments: Segment[]; parseFaile
|
|
|
408
532
|
if (ch === "|" || ch === ";" || ch === "&") {
|
|
409
533
|
// `&&`, `||`, `;`, `|`, `&` all end a segment. Any of them means the
|
|
410
534
|
// next command is separate, which is all we need to know.
|
|
411
|
-
|
|
412
|
-
|
|
535
|
+
const doubled = command[i + 1] === ch;
|
|
536
|
+
pushSegment(i, doubled ? 2 : 1);
|
|
537
|
+
if (doubled) i++;
|
|
413
538
|
continue;
|
|
414
539
|
}
|
|
415
540
|
|
|
@@ -422,7 +547,7 @@ export function parseCommand(command: string): { segments: Segment[]; parseFaile
|
|
|
422
547
|
}
|
|
423
548
|
|
|
424
549
|
/** Peel wrappers to the command that actually runs (review finding N5). */
|
|
425
|
-
function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peeled: string[] } {
|
|
550
|
+
export function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peeled: string[] } {
|
|
426
551
|
const peeled: string[] = [];
|
|
427
552
|
let index = 0;
|
|
428
553
|
|
|
@@ -449,6 +574,69 @@ function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peel
|
|
|
449
574
|
}
|
|
450
575
|
}
|
|
451
576
|
|
|
577
|
+
/**
|
|
578
|
+
* A segment's tokens normalised for command-position checks: subshell parens
|
|
579
|
+
* stripped from the head token only (`(cd` → `cd` — a later argument may
|
|
580
|
+
* legitimately begin with `(`), leading `do`/`then`/`else` keywords skipped,
|
|
581
|
+
* and as many trailing `)`s stripped off the last token as `(`s were opened at
|
|
582
|
+
* the head, so `(git stash)` still matches subcommand `stash`. A subshell
|
|
583
|
+
* whose closing paren lands in a *different* segment (`(a && git stash)`) is
|
|
584
|
+
* a known limitation — the closer is only balanced within one segment.
|
|
585
|
+
* Used by the bash/worktree guard pipelines, not by the escalation analyzer.
|
|
586
|
+
*/
|
|
587
|
+
export function leadTokens(seg: Segment): Token[] {
|
|
588
|
+
const tokens = [...seg.tokens];
|
|
589
|
+
let i = 0;
|
|
590
|
+
let opened = 0;
|
|
591
|
+
for (;;) {
|
|
592
|
+
while (i < tokens.length) {
|
|
593
|
+
const value = tokens[i].value;
|
|
594
|
+
const lead = /^[({]+/.exec(value)?.[0] ?? "";
|
|
595
|
+
if (lead.length === 0) break;
|
|
596
|
+
opened += (lead.match(/\(/g) ?? []).length;
|
|
597
|
+
const stripped = value.slice(lead.length);
|
|
598
|
+
if (stripped.length === 0) {
|
|
599
|
+
i++;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
tokens[i] = { ...tokens[i], value: stripped };
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
if (i < tokens.length && ["do", "then", "else"].includes(tokens[i].value)) {
|
|
606
|
+
i++;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
if (opened > 0 && tokens.length > i) {
|
|
612
|
+
const last = tokens.length - 1;
|
|
613
|
+
const trailing = /\)+$/.exec(tokens[last].value)?.[0];
|
|
614
|
+
if (trailing) {
|
|
615
|
+
const strip = Math.min(trailing.length, opened);
|
|
616
|
+
tokens[last] = { ...tokens[last], value: tokens[last].value.slice(0, tokens[last].value.length - strip) };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return tokens.slice(i);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Superset of GIT_GLOBAL_VALUE_FLAGS for *locating* the subcommand, not for
|
|
624
|
+
* judging safety: `-c`/`--config-env` are included here because they do take a
|
|
625
|
+
* value, while the escalation analyzer above deliberately refuses to skip them.
|
|
626
|
+
*/
|
|
627
|
+
const GIT_LOCATOR_VALUE_FLAGS = new Set([...GIT_GLOBAL_VALUE_FLAGS, "-c", "--config-env"]);
|
|
628
|
+
|
|
629
|
+
/** The git subcommand and the tokens after it, global flags skipped. */
|
|
630
|
+
export function gitSubcommand(args: Token[]): { sub?: string; rest: Token[] } {
|
|
631
|
+
let i = 0;
|
|
632
|
+
while (i < args.length) {
|
|
633
|
+
const value = args[i].value;
|
|
634
|
+
if (!value.startsWith("-")) return { sub: value, rest: args.slice(i + 1) };
|
|
635
|
+
i += GIT_LOCATOR_VALUE_FLAGS.has(value) ? 2 : 1;
|
|
636
|
+
}
|
|
637
|
+
return { rest: [] };
|
|
638
|
+
}
|
|
639
|
+
|
|
452
640
|
/** Basename of a command token, `.exe` stripped, lowercased. */
|
|
453
641
|
function commandName(token: string): string {
|
|
454
642
|
const withForwardSlashes = token.trim().toLowerCase().replace(/\\/g, "/");
|
|
@@ -501,16 +689,19 @@ function gitEscalationReason(args: Token[], isDirOutsideCwd: (dir: string) => bo
|
|
|
501
689
|
if (token === "-c" || token.startsWith("-c=") || token === "--config-env") {
|
|
502
690
|
return "passes git -c/--config-env, which can turn a read into code execution";
|
|
503
691
|
}
|
|
504
|
-
|
|
505
|
-
|
|
692
|
+
// `--git-dir=/x` spells the same flag as `--git-dir /x`; normalise both.
|
|
693
|
+
const eq = token.indexOf("=");
|
|
694
|
+
const flag = eq > 0 ? token.slice(0, eq) : token;
|
|
695
|
+
if (GIT_GLOBAL_VALUE_FLAGS.has(flag)) {
|
|
696
|
+
const value = eq > 0 ? token.slice(eq + 1) : args[index + 1]?.value;
|
|
506
697
|
// `-C`/`--git-dir`/`--work-tree` retarget git at another directory. If that
|
|
507
698
|
// directory escapes the working directory the operation is no longer
|
|
508
699
|
// provably in-project, so escalate rather than skipping the flag blindly
|
|
509
700
|
// (was review gap: `git -C /etc status` classified safe).
|
|
510
|
-
if ((
|
|
511
|
-
return `runs git ${
|
|
701
|
+
if ((flag === "-C" || flag === "--git-dir" || flag === "--work-tree") && value && isDirOutsideCwd(value)) {
|
|
702
|
+
return `runs git ${flag} ${value}, which points outside the working directory`;
|
|
512
703
|
}
|
|
513
|
-
index += 2;
|
|
704
|
+
index += eq > 0 ? 1 : 2;
|
|
514
705
|
continue;
|
|
515
706
|
}
|
|
516
707
|
index++;
|
|
@@ -525,12 +716,18 @@ export interface AnalyzeInput {
|
|
|
525
716
|
command: string;
|
|
526
717
|
cwd: string;
|
|
527
718
|
home: string;
|
|
719
|
+
/**
|
|
720
|
+
* Directories protected at runtime beyond protected-paths.ts's static list
|
|
721
|
+
* (pi's own agent dir — permissions/matcher.ts `DecideInput.protectedDirs`).
|
|
722
|
+
* A redirect landing inside one escalates like a write to `.git/hooks`.
|
|
723
|
+
*/
|
|
724
|
+
protectedDirs?: string[];
|
|
528
725
|
}
|
|
529
726
|
|
|
530
727
|
/**
|
|
531
728
|
* Classify a shell command. Never denies — see the module contract above.
|
|
532
729
|
*/
|
|
533
|
-
export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): ShellEvidence {
|
|
730
|
+
export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }: AnalyzeInput): ShellEvidence {
|
|
534
731
|
const evidence: ShellEvidence = {
|
|
535
732
|
verdict: "safe",
|
|
536
733
|
notes: [],
|
|
@@ -538,10 +735,26 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
|
|
|
538
735
|
writes: [],
|
|
539
736
|
sensitivePaths: [],
|
|
540
737
|
executionPrimitives: [],
|
|
738
|
+
protectedPaths: [],
|
|
541
739
|
network: [],
|
|
740
|
+
outsideReads: [],
|
|
741
|
+
readOnlyOutside: false,
|
|
742
|
+
containedNonNetwork: false,
|
|
743
|
+
wholeTree: false,
|
|
542
744
|
};
|
|
543
|
-
|
|
745
|
+
/**
|
|
746
|
+
* Set by any escalation reason that is NOT a bare in-project mutation — i.e.
|
|
747
|
+
* anything meaning the command reaches outside the project or cannot be fully
|
|
748
|
+
* accounted for. When it stays false through an escalation, the only blocker
|
|
749
|
+
* was in-project mutation, and the containment gate may consult recoverability.
|
|
750
|
+
*/
|
|
751
|
+
let uncontained = false;
|
|
752
|
+
/** Set by any escalation that is not an outside-cwd read (see readOnlyOutside). */
|
|
753
|
+
let escalatedBeyondReads = false;
|
|
754
|
+
const escalate = (note: string, opts?: { contained?: boolean; outsideRead?: boolean }) => {
|
|
544
755
|
evidence.verdict = "escalate";
|
|
756
|
+
if (!opts?.contained) uncontained = true;
|
|
757
|
+
if (!opts?.outsideRead) escalatedBeyondReads = true;
|
|
545
758
|
if (!evidence.notes.includes(note)) evidence.notes.push(note);
|
|
546
759
|
};
|
|
547
760
|
|
|
@@ -594,6 +807,18 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
|
|
|
594
807
|
evidence.executionPrimitives.push(token);
|
|
595
808
|
escalate(`writes to ${token}, whose contents execute later without further approval`);
|
|
596
809
|
}
|
|
810
|
+
// Same list the write/edit tools are gated on: an in-project redirect onto
|
|
811
|
+
// `.cargo/config.toml`, `.claude/agents/x.md`, or `lefthook.yml` reconfigures
|
|
812
|
+
// the toolchain or the agent itself, so containment says nothing about it.
|
|
813
|
+
const protectedTarget = [absolute, resolved].some(
|
|
814
|
+
(candidate) =>
|
|
815
|
+
candidate !== undefined &&
|
|
816
|
+
(isProtectedPath(candidate, effectiveCwd) || protectedDirs.some((dir) => isWithin(dir, candidate))),
|
|
817
|
+
);
|
|
818
|
+
if (protectedTarget && !evidence.protectedPaths.includes(token)) {
|
|
819
|
+
evidence.protectedPaths.push(token);
|
|
820
|
+
escalate(`writes to ${token}, a protected tooling or agent configuration path`);
|
|
821
|
+
}
|
|
597
822
|
};
|
|
598
823
|
|
|
599
824
|
for (const segment of segments) {
|
|
@@ -605,10 +830,27 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
|
|
|
605
830
|
for (const token of segment.redirects) checkWriteTarget(token);
|
|
606
831
|
|
|
607
832
|
const { command: name, args, peeled } = resolvePayload(segment.tokens);
|
|
608
|
-
if (!name)
|
|
833
|
+
if (!name) {
|
|
834
|
+
// A wrapper with nothing to wrap is a command of its own: a bare `env`
|
|
835
|
+
// (or `env -i`, `nice`) prints the whole process environment / state.
|
|
836
|
+
// Until 2026-09-05 this fell through as "safe" (H2).
|
|
837
|
+
if (peeled.length > 0) {
|
|
838
|
+
escalate(
|
|
839
|
+
peeled.includes("env")
|
|
840
|
+
? "runs env with no command, which prints the whole process environment"
|
|
841
|
+
: `runs ${peeled.join(" → ")} with no command to wrap`,
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
609
846
|
evidence.commands.push(name);
|
|
610
847
|
if (peeled.length > 0) {
|
|
611
|
-
|
|
848
|
+
// A transparent wrapper (timeout, env, nice) leaves the payload's own
|
|
849
|
+
// arguments visible, so an in-project payload stays contained; xargs is
|
|
850
|
+
// the exception — its targets come from stdin, unknown to this check.
|
|
851
|
+
escalate(`wraps the real command in ${peeled.join(" → ")}, so ${name} is what actually runs`, {
|
|
852
|
+
contained: !peeled.includes("xargs"),
|
|
853
|
+
});
|
|
612
854
|
}
|
|
613
855
|
|
|
614
856
|
if (NETWORK_COMMANDS.has(name)) {
|
|
@@ -643,6 +885,22 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
|
|
|
643
885
|
}
|
|
644
886
|
|
|
645
887
|
if (name === "git") {
|
|
888
|
+
// `git reset --hard` is an in-project whole-tree discard: escalate, but
|
|
889
|
+
// mark it contained so the recoverability gate can clear it when the tree
|
|
890
|
+
// is clean. Any other non-read-only git subcommand is uncontained.
|
|
891
|
+
if (isWholeTreeGitReset(args)) {
|
|
892
|
+
if (hasGitRetargetFlag(args)) {
|
|
893
|
+
// The reset acts on whatever -C/--git-dir/--work-tree names, not on
|
|
894
|
+
// the working directory the recoverability judge would inspect.
|
|
895
|
+
escalate("runs git reset --hard against another tree (-C/--git-dir/--work-tree), which cannot be judged here");
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
evidence.wholeTree = true;
|
|
899
|
+
escalate("runs git reset --hard, which discards uncommitted changes in the working tree", {
|
|
900
|
+
contained: true,
|
|
901
|
+
});
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
646
904
|
const reason = gitEscalationReason(args, (dir) => {
|
|
647
905
|
const resolved = resolveForContainment(toAbsolute(effectiveCwd, dir, home));
|
|
648
906
|
return resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
@@ -660,16 +918,58 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
|
|
|
660
918
|
}
|
|
661
919
|
|
|
662
920
|
const isMutation = MUTATION_COMMANDS.has(name);
|
|
663
|
-
|
|
921
|
+
const isDelete = DELETE_COMMANDS.has(name);
|
|
922
|
+
// A delete confined to in-project paths is what the containment gate exists
|
|
923
|
+
// to clear (subject to recoverability); any other mutation (cp/mv/tar/…)
|
|
924
|
+
// stays uncontained and reaches the classifier as before.
|
|
925
|
+
if (isMutation) escalate(`runs ${name}, which modifies the filesystem`, { contained: isDelete });
|
|
664
926
|
|
|
665
927
|
if (!isMutation && !READ_ONLY_COMMANDS.has(name) && !PATTERN_FIRST_COMMANDS.has(name) && name !== "find") {
|
|
666
928
|
escalate(`runs ${name}, which is not on the read-only allowlist`);
|
|
667
929
|
}
|
|
668
930
|
|
|
931
|
+
// A read-only command's path operands are read: one that resolves outside
|
|
932
|
+
// the working directory escalates (Claude Code asks for every read outside
|
|
933
|
+
// it — filesystem.ts — and removed env/printenv from its read-only list).
|
|
934
|
+
// Bare names (`cat notes.txt`) resolve inside the cwd and stay fast-pathed;
|
|
935
|
+
// a pattern-first command's first positional is its pattern, not a path.
|
|
936
|
+
if (!isMutation && !NO_FILE_OPERANDS.has(name)) {
|
|
937
|
+
const positionals = args.filter((token) => !token.value.startsWith("-")).map((token) => token.value);
|
|
938
|
+
// The first positional is the pattern only when no flag supplies it:
|
|
939
|
+
// with `-e PAT` / `-f FILE` (`--regexp`, `--file`) every positional is a
|
|
940
|
+
// path, and `-f`'s value is itself a file read.
|
|
941
|
+
const patternByFlag = args.some(({ value }) => /^(-[a-zA-Z]*[ef][a-zA-Z]*|--regexp(=.*)?|--file(=.*)?)$/.test(value));
|
|
942
|
+
if (PATTERN_FIRST_COMMANDS.has(name) && !patternByFlag) positionals.shift();
|
|
943
|
+
// `--file=FILE` carries its read inside the flag token.
|
|
944
|
+
for (const { value } of args) {
|
|
945
|
+
if (value.startsWith("--file=")) positionals.push(value.slice("--file=".length));
|
|
946
|
+
}
|
|
947
|
+
for (const value of positionals) {
|
|
948
|
+
if (!looksLikePath(value)) continue;
|
|
949
|
+
const resolved = resolveForContainment(toAbsolute(effectiveCwd, value, home));
|
|
950
|
+
if (resolved !== undefined && isWithin(containmentRoot, resolved)) continue;
|
|
951
|
+
if (!evidence.outsideReads.includes(value)) evidence.outsideReads.push(value);
|
|
952
|
+
escalate(`reads ${value}, which is outside the working directory`, { outsideRead: true });
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
669
956
|
// The positional destinations of writing commands are the paths that get
|
|
670
957
|
// written. (Redirections were already handled at the top of the loop.)
|
|
671
958
|
const writeTokens: string[] = [];
|
|
672
|
-
if (
|
|
959
|
+
if (isDelete) {
|
|
960
|
+
// A delete's targets are every non-flag positional, bare names included
|
|
961
|
+
// (`rm notes.txt` has no slash but is still a real target the
|
|
962
|
+
// recoverability gate must see). A glob target cannot be enumerated, so
|
|
963
|
+
// it drops out of containment — the command then reaches the classifier.
|
|
964
|
+
for (const token of args) {
|
|
965
|
+
if (token.value.startsWith("-")) continue;
|
|
966
|
+
if (hasGlob(token.value)) {
|
|
967
|
+
escalate(`targets ${token.value}, a glob whose expansion cannot be checked`);
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
writeTokens.push(token.value);
|
|
971
|
+
}
|
|
972
|
+
} else if (isMutation) {
|
|
673
973
|
const positionals = args.filter((token) => !token.value.startsWith("-") && looksLikePath(token.value));
|
|
674
974
|
// For cp/mv/rsync/ln the destination is last; for the rest every
|
|
675
975
|
// positional is a candidate target.
|
|
@@ -698,5 +998,11 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
|
|
|
698
998
|
for (const token of writeTokens) checkWriteTarget(token);
|
|
699
999
|
}
|
|
700
1000
|
|
|
1001
|
+
// The command escalated, but every reason was an in-project delete/whole-tree
|
|
1002
|
+
// reset — nothing reached outside the project, the network, or the unknown. The
|
|
1003
|
+
// containment gate may now consult git-recoverability (auto-mode/recoverability).
|
|
1004
|
+
evidence.containedNonNetwork = evidence.verdict === "escalate" && !uncontained;
|
|
1005
|
+
evidence.readOnlyOutside = evidence.verdict === "escalate" && !escalatedBeyondReads;
|
|
1006
|
+
|
|
701
1007
|
return evidence;
|
|
702
1008
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<transcript>` block the classifier reads (pure).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code hands its classifier a compact JSONL transcript of the session —
|
|
5
|
+
* user messages and tool-call *inputs only*, with tool RESULTS stripped so
|
|
6
|
+
* hostile content the agent read cannot reach the classifier as if it were
|
|
7
|
+
* context. Tool names are CC's PascalCase (`Bash`, `Edit`, …). We mirror that
|
|
8
|
+
* shape exactly (see docs/decisions/auto-mode.md, P5).
|
|
9
|
+
*
|
|
10
|
+
* The last entry is always the action under review — permissions/index.ts appends
|
|
11
|
+
* the call being judged before rendering. Results are never appended here; only
|
|
12
|
+
* user messages and tool inputs enter, which is the isolation boundary.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Native (snake_case) tool name → Claude Code's PascalCase spelling, reused from
|
|
16
|
+
// the hooks matcher (its `ccToolName` is the correct-direction, mcp-passthrough
|
|
17
|
+
// map; permissions/matcher.ts's is lower-cased and cannot recover casing).
|
|
18
|
+
import { ccToolName } from "../hooks/matcher.ts";
|
|
19
|
+
|
|
20
|
+
export { ccToolName };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One line of the transcript: a user message, a tool call's input, or a call the
|
|
24
|
+
* permission rules refused. A `denied` line is harness-generated, never tool
|
|
25
|
+
* output, so it does not weaken the results-stripped isolation boundary — and
|
|
26
|
+
* without it the classifier could not see that the rules had already refused
|
|
27
|
+
* this class of action, so it cleared an equivalent-effect retry on the user's
|
|
28
|
+
* original intent (WEAK-MODEL-REVIEW-2026-09-06 H2).
|
|
29
|
+
*/
|
|
30
|
+
export type TranscriptEntry =
|
|
31
|
+
| { kind: "user"; text: string }
|
|
32
|
+
| { kind: "tool"; tool: string; input: Record<string, unknown> }
|
|
33
|
+
| { kind: "denied"; tool: string; subject: string; rule: string };
|
|
34
|
+
|
|
35
|
+
/** Truncate one field so a single huge argument cannot dominate the transcript. */
|
|
36
|
+
export function clip(value: string, max: number): string {
|
|
37
|
+
return value.length <= max ? value : `${value.slice(0, max)}… [truncated, ${value.length} chars]`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Clip every string leaf of a tool input, leaving structure intact. */
|
|
41
|
+
function clipInput(input: Record<string, unknown>, max: number): Record<string, unknown> {
|
|
42
|
+
const out: Record<string, unknown> = {};
|
|
43
|
+
for (const [key, value] of Object.entries(input)) {
|
|
44
|
+
out[key] = typeof value === "string" ? clip(value, max) : value;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render one entry as its compact JSON line. Bash renders as `{"Bash":"<command>"}`
|
|
51
|
+
* (the command string, as Claude Code does); every other tool renders as
|
|
52
|
+
* `{"<Tool>":{…input…}}` with string fields clipped.
|
|
53
|
+
*/
|
|
54
|
+
function renderEntry(entry: TranscriptEntry, maxField: number): string {
|
|
55
|
+
if (entry.kind === "user") return JSON.stringify({ user: clip(entry.text, maxField) });
|
|
56
|
+
if (entry.kind === "denied") {
|
|
57
|
+
return JSON.stringify({
|
|
58
|
+
denied_by_permission_rule: { tool: ccToolName(entry.tool), attempted: clip(entry.subject, maxField), rule: entry.rule },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const name = ccToolName(entry.tool);
|
|
62
|
+
const command = entry.input.command;
|
|
63
|
+
if (entry.tool === "bash" && typeof command === "string") {
|
|
64
|
+
return JSON.stringify({ [name]: clip(command, maxField) });
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify({ [name]: clipInput(entry.input, maxField) });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RenderOptions {
|
|
70
|
+
/** Max chars per string field. */
|
|
71
|
+
maxField?: number;
|
|
72
|
+
/** Max chars for the whole rendered transcript; oldest entries drop first. */
|
|
73
|
+
maxChars?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render the ordered entries into a `<transcript>…</transcript>` block. When the
|
|
78
|
+
* rendered lines exceed `maxChars`, the oldest are dropped and a marker records
|
|
79
|
+
* it — the action under review (the last entry) is always kept. The full user
|
|
80
|
+
* messages are carried separately for intent verification, so dropping old lines
|
|
81
|
+
* here never weakens that check.
|
|
82
|
+
*/
|
|
83
|
+
export function renderTranscript(entries: TranscriptEntry[], options: RenderOptions = {}): string {
|
|
84
|
+
const maxField = options.maxField ?? 2000;
|
|
85
|
+
const maxChars = options.maxChars ?? 60_000;
|
|
86
|
+
const lines = entries.map((entry) => renderEntry(entry, maxField));
|
|
87
|
+
|
|
88
|
+
// Keep the newest lines that fit the budget, walking from the end in one pass
|
|
89
|
+
// (the last line — the action under review — is always kept). +1 per line for
|
|
90
|
+
// the joining "\n".
|
|
91
|
+
let firstKept = lines.length;
|
|
92
|
+
let running = 0;
|
|
93
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
94
|
+
running += lines[i].length + 1;
|
|
95
|
+
if (i < lines.length - 1 && running > maxChars) break;
|
|
96
|
+
firstKept = i;
|
|
97
|
+
}
|
|
98
|
+
const kept = lines.slice(firstKept);
|
|
99
|
+
const dropped = firstKept;
|
|
100
|
+
const body = dropped > 0 ? [`{"note":"${dropped} earlier transcript entr${dropped === 1 ? "y" : "ies"} omitted for length"}`, ...kept] : kept;
|
|
101
|
+
return `<transcript>\n${body.join("\n")}\n</transcript>`;
|
|
102
|
+
}
|