one-code-extension 0.4.1 → 0.5.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 +16 -5
- package/agents/general-purpose.md +2 -2
- package/docs/guide/README.md +2 -1
- package/docs/guide/bring-your-claude-code-setup.md +60 -9
- package/docs/guide/configuration.md +48 -14
- package/docs/guide/differences-from-claude-code.md +32 -23
- package/docs/guide/doctor.md +20 -13
- package/docs/guide/hooks.md +18 -3
- package/docs/guide/installation.md +27 -4
- package/docs/guide/permissions-modes-and-auto-mode.md +225 -49
- package/docs/guide/providers-and-models.md +137 -54
- package/docs/guide/reference.md +27 -15
- package/docs/guide/sessions-and-context.md +69 -16
- package/docs/guide/skills-plugins-and-mcp.md +57 -20
- package/docs/guide/subagents-and-workflows.md +95 -29
- package/docs/guide/tasks-and-background-work.md +101 -26
- package/docs/guide/terminal-interface.md +24 -12
- package/docs/guide/tools.md +46 -5
- package/docs/guide/troubleshooting.md +13 -9
- package/docs/guide/windows.md +58 -31
- package/extensions/artifacts/gallery.ts +227 -0
- package/extensions/artifacts/index.ts +288 -0
- package/extensions/artifacts/store.ts +570 -0
- package/extensions/artifacts/viewer.ts +199 -0
- package/extensions/ask-user/description.ts +67 -0
- package/extensions/ask-user/dialogs.ts +80 -0
- package/extensions/ask-user/index.ts +37 -33
- package/extensions/ask-user/questions.ts +32 -7
- package/extensions/ask-user/widget.ts +45 -25
- package/extensions/auto-mode/actions.ts +6 -0
- package/extensions/auto-mode/cache.ts +88 -0
- package/extensions/auto-mode/classifier-prompt.ts +5 -4
- package/extensions/auto-mode/classifier.ts +290 -65
- package/extensions/auto-mode/config.ts +72 -20
- package/extensions/auto-mode/decision-log.ts +2 -0
- package/extensions/auto-mode/defaults.ts +1 -1
- package/extensions/auto-mode/denials.ts +123 -0
- package/extensions/auto-mode/git-checkout-programs.ts +187 -0
- package/extensions/auto-mode/git-status-meta.ts +159 -0
- package/extensions/auto-mode/history.ts +189 -0
- package/extensions/auto-mode/instructions.ts +58 -52
- package/extensions/auto-mode/model-select.ts +159 -134
- package/extensions/auto-mode/paths.ts +57 -5
- package/extensions/auto-mode/pause.ts +57 -2
- package/extensions/auto-mode/prompt.ts +37 -21
- package/extensions/auto-mode/read-only-options.ts +724 -0
- package/extensions/auto-mode/resolved-paths-meta.ts +133 -0
- package/extensions/auto-mode/rules.ts +18 -1
- package/extensions/auto-mode/safety-floor.ts +361 -18
- package/extensions/auto-mode/setup-run.ts +20 -14
- package/extensions/auto-mode/shell-analysis.ts +764 -490
- package/extensions/auto-mode/shell-parse.ts +885 -0
- package/extensions/auto-mode/system-note.ts +33 -0
- package/extensions/auto-mode/transcript.ts +80 -51
- package/extensions/background/cron.ts +515 -0
- package/extensions/background/index.ts +514 -236
- package/extensions/background/loop-fire.ts +136 -0
- package/extensions/background/loop-preamble.ts +59 -0
- package/extensions/background/loop-skill.ts +163 -0
- package/extensions/background/monitor-batch.ts +53 -0
- package/extensions/background/noop-fold.ts +128 -0
- package/extensions/background/switch-guard.ts +52 -0
- package/extensions/background/wakeup.ts +159 -55
- package/extensions/bash/background.ts +53 -17
- package/extensions/bash/child.ts +7 -0
- package/extensions/bash/description.ts +188 -0
- package/extensions/bash/guards.ts +15 -67
- package/extensions/bash/index.ts +88 -35
- package/extensions/branding/compaction-keep.ts +63 -0
- package/extensions/branding/declined.ts +22 -0
- package/extensions/branding/index.ts +133 -9
- package/extensions/branding/prompt-editor.ts +53 -6
- package/extensions/branding/queued-edit.ts +31 -0
- package/extensions/branding/replaced-builtins.ts +68 -0
- package/extensions/branding/startup.ts +33 -71
- package/extensions/btw/index.ts +229 -71
- package/extensions/btw/panel.ts +228 -100
- package/extensions/btw/prompt.ts +41 -4
- package/extensions/claude-compat/index.ts +7 -11
- package/extensions/claude-context/index.ts +446 -58
- package/extensions/compaction/cut.ts +157 -0
- package/extensions/compaction/index.ts +216 -75
- package/extensions/compaction/kept-thinking.ts +62 -0
- package/extensions/compaction/prompt.ts +15 -12
- package/extensions/context-budget/index.ts +24 -5
- package/extensions/context-management/index.ts +25 -0
- package/extensions/doctor/build.ts +59 -8
- package/extensions/doctor/builtins.ts +84 -0
- package/extensions/doctor/cli.ts +7 -1
- package/extensions/doctor/compat.ts +88 -23
- package/extensions/doctor/dependencies.ts +41 -5
- package/extensions/doctor/fix-prompt.ts +9 -2
- package/extensions/doctor/index.ts +102 -58
- package/extensions/doctor/models.ts +102 -63
- package/extensions/doctor/presets.ts +42 -27
- package/extensions/doctor/report.ts +19 -2
- package/extensions/doctor/viewer.ts +49 -27
- package/extensions/effort/index.ts +72 -19
- package/extensions/effort/settings.ts +21 -0
- package/extensions/effort/slider.ts +24 -20
- package/extensions/exit/index.ts +32 -1
- package/extensions/file-tracker/index.ts +337 -26
- package/extensions/file-tracker/replay.ts +96 -10
- package/extensions/file-tracker/restore.ts +63 -0
- package/extensions/file-tracker/results.ts +66 -0
- package/extensions/file-tracker/shell-reads.ts +211 -0
- package/extensions/file-tracker/tracker.ts +32 -15
- package/extensions/footer/footer-line.ts +45 -24
- package/extensions/footer/index.ts +47 -2
- package/extensions/footer/pr.ts +34 -5
- package/extensions/hooks/compaction.ts +7 -0
- package/extensions/hooks/executor.ts +32 -30
- package/extensions/hooks/index.ts +198 -31
- package/extensions/hooks/matcher.ts +3 -0
- package/extensions/hooks/protocol.ts +41 -3
- package/extensions/hooks/settings.ts +50 -19
- package/extensions/hooks/subagent-bridge.ts +2 -0
- package/extensions/hooks/trust.ts +13 -5
- package/extensions/idle-compact/index.ts +119 -0
- package/extensions/idle-compact/policy.ts +143 -0
- package/extensions/init/index.ts +11 -10
- package/extensions/init/prompt.ts +25 -32
- package/extensions/interrupted/index.ts +13 -1
- package/extensions/kept-thinking/index.ts +27 -0
- package/extensions/lib/agent-cron.ts +130 -0
- package/extensions/lib/agent-loader.ts +15 -1
- package/extensions/lib/agent-view.ts +85 -0
- package/extensions/lib/anthropic-payload.ts +82 -1
- package/extensions/lib/anthropic-server-call.ts +230 -0
- package/extensions/lib/anthropic-server-tools.ts +485 -0
- package/extensions/lib/app-launch.mjs +94 -0
- package/extensions/lib/argument-hints.ts +190 -0
- package/extensions/lib/atomic-write.ts +6 -2
- package/extensions/lib/auto-memory.ts +75 -0
- package/extensions/lib/bash-parser.ts +84 -0
- package/extensions/lib/btw-fork.ts +93 -0
- package/extensions/lib/child-extensions.ts +42 -0
- package/extensions/lib/child-writes.ts +45 -0
- package/extensions/lib/claude-context.ts +515 -100
- package/extensions/lib/claude-external-includes.ts +68 -0
- package/extensions/lib/claude-id.ts +22 -0
- package/extensions/lib/claude-rules.ts +323 -0
- package/extensions/lib/claude-settings.ts +76 -4
- package/extensions/lib/compaction-boundary.ts +36 -0
- package/extensions/lib/compaction-keep.mjs +83 -0
- package/extensions/lib/config-mode.ts +112 -0
- package/extensions/lib/consent-dialogs.ts +63 -0
- package/extensions/lib/consent-preview.ts +5 -1
- package/extensions/lib/consent-stores.ts +48 -0
- package/extensions/lib/context-facts.ts +78 -0
- package/extensions/lib/context-stack.ts +81 -0
- package/extensions/lib/deferred.ts +219 -25
- package/extensions/lib/dock-rows.ts +60 -0
- package/extensions/lib/empty-tool-result.ts +27 -0
- package/extensions/lib/environment-block.ts +76 -0
- package/extensions/lib/fetch-timeout.ts +24 -6
- package/extensions/{system-prompt → lib}/git-status.ts +51 -13
- package/extensions/lib/git.ts +75 -13
- package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
- package/extensions/lib/grammars/README.md +2 -0
- package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
- package/extensions/lib/guide-docs.ts +45 -0
- package/extensions/lib/headless-output.ts +46 -0
- package/extensions/lib/hook-gate.ts +7 -7
- package/extensions/lib/interrupt.ts +20 -4
- package/extensions/lib/key-input.ts +455 -0
- package/extensions/lib/keys.ts +14 -1
- package/extensions/lib/local-command.ts +47 -13
- package/extensions/lib/mcp-overrides.ts +1 -1
- package/extensions/lib/mcp-share.ts +46 -0
- package/extensions/lib/memory-content.ts +146 -0
- package/extensions/lib/memory-index.ts +18 -0
- package/extensions/lib/memory.ts +134 -123
- package/extensions/lib/model-catalog/huggingface.json +174 -0
- package/extensions/lib/model-catalog/models-dev.json +18297 -0
- package/extensions/lib/model-catalog/openrouter.json +5252 -0
- package/extensions/lib/model-catalog-data.ts +513 -0
- package/extensions/lib/model-catalog.ts +462 -0
- package/extensions/lib/model-choice-warnings.ts +137 -0
- package/extensions/lib/model-pairings.ts +152 -0
- package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
- package/extensions/lib/model-policy.ts +112 -15
- package/extensions/lib/model-tier.ts +283 -355
- package/extensions/lib/model-unusable.ts +1 -1
- package/extensions/lib/newer-model.ts +100 -0
- package/extensions/lib/notices.ts +87 -0
- package/extensions/lib/notifications.ts +308 -64
- package/extensions/lib/one-code-settings.ts +74 -8
- package/extensions/lib/open-browser.ts +63 -0
- package/extensions/lib/openrouter-generation.ts +94 -0
- package/extensions/lib/operation-deadline.ts +30 -0
- package/extensions/lib/original-command.ts +4 -4
- package/extensions/lib/package-version.ts +1 -1
- package/extensions/lib/panel-focus.ts +134 -0
- package/extensions/lib/paths.ts +17 -2
- package/extensions/lib/permission-gate.ts +72 -6
- package/extensions/lib/persisted-output.ts +60 -8
- package/extensions/lib/pi-ai-estimate.ts +19 -3
- package/extensions/lib/pi-version.ts +21 -2
- package/extensions/lib/plan-mode-channels.ts +20 -5
- package/extensions/lib/plugins.ts +26 -17
- package/extensions/lib/powershell-parse-script.ts +208 -0
- package/extensions/lib/powershell-parser.ts +344 -0
- package/extensions/lib/process-tree.ts +131 -8
- package/extensions/lib/prompt-options.ts +82 -0
- package/extensions/lib/queued-delivery.ts +44 -0
- package/extensions/lib/reminders.ts +364 -58
- package/extensions/lib/replaced-builtins.mjs +199 -0
- package/extensions/lib/replay-call.ts +70 -0
- package/extensions/lib/request-replay.ts +439 -0
- package/extensions/lib/scratchpad.ts +81 -14
- package/extensions/lib/send-now-channel.ts +7 -0
- package/extensions/lib/session-model-tier.ts +51 -0
- package/extensions/lib/session-work.ts +64 -0
- package/extensions/lib/settings-channels.ts +4 -4
- package/extensions/lib/shell-exit-semantics.ts +170 -0
- package/extensions/lib/shell-quote.ts +11 -0
- package/extensions/lib/shell-result.ts +70 -0
- package/extensions/lib/shell-spawn.ts +70 -6
- package/extensions/lib/shell-tool.ts +75 -25
- package/extensions/lib/side-call-cache.ts +124 -0
- package/extensions/lib/side-call-run.ts +96 -0
- package/extensions/lib/side-call-usage.ts +38 -0
- package/extensions/lib/side-call.ts +13 -1
- package/extensions/lib/single-flight.ts +18 -0
- package/extensions/lib/skill-body.ts +42 -0
- package/extensions/lib/skill-scan.ts +63 -24
- package/extensions/lib/spill-file.ts +140 -0
- package/extensions/lib/subagent-handback.ts +113 -0
- package/extensions/lib/system-role.ts +156 -0
- package/extensions/lib/terminal-text.ts +114 -0
- package/extensions/lib/text-width.ts +47 -16
- package/extensions/lib/timer-ops.ts +20 -0
- package/extensions/lib/tool-additions.ts +149 -0
- package/extensions/lib/tool-call-corruption.ts +134 -0
- package/extensions/lib/tool-path.ts +66 -0
- package/extensions/lib/tool-variants.ts +69 -0
- package/extensions/lib/tui-render.ts +255 -27
- package/extensions/lib/turn-budget-layout.ts +143 -0
- package/extensions/lib/update-check.mjs +15 -2
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/user-denial.ts +38 -0
- package/extensions/lib/workspace-channel.ts +16 -0
- package/extensions/lib/worktree-channel.ts +31 -3
- package/extensions/lib/worktree-isolation.ts +40 -10
- package/extensions/lsp/client.ts +8 -9
- package/extensions/lsp/index.ts +70 -4
- package/extensions/lsp/keep-alive.ts +1 -1
- package/extensions/lsp/trust.ts +119 -0
- package/extensions/lsp/watcher.ts +1 -1
- package/extensions/mcp/announce.ts +87 -5
- package/extensions/mcp/client.ts +161 -48
- package/extensions/mcp/config.ts +132 -8
- package/extensions/mcp/discovery.ts +19 -0
- package/extensions/mcp/index.ts +209 -71
- package/extensions/mcp/oauth/browser.ts +38 -17
- package/extensions/mcp/oauth/flow.ts +25 -17
- package/extensions/mcp/oauth/provider.ts +14 -8
- package/extensions/mcp/oauth/store.ts +34 -16
- package/extensions/mcp/panel/keys.ts +12 -11
- package/extensions/mcp/panel/render.ts +9 -6
- package/extensions/mcp/schema.ts +104 -5
- package/extensions/mcp/trust.ts +151 -67
- package/extensions/memory/entries.ts +23 -14
- package/extensions/memory/index.ts +134 -28
- package/extensions/memory/open-external.ts +33 -18
- package/extensions/memory/panel.ts +135 -26
- package/extensions/newer-model/index.ts +31 -0
- package/extensions/notebook/description.ts +18 -0
- package/extensions/notebook/index.ts +6 -5
- package/extensions/notebook/notebook.ts +1 -1
- package/extensions/permissions/index.ts +1300 -381
- package/extensions/permissions/matcher.ts +425 -74
- package/extensions/permissions/modes.ts +6 -4
- package/extensions/permissions/panel/host.ts +130 -0
- package/extensions/permissions/panel/keys.ts +48 -0
- package/extensions/permissions/panel/render.ts +450 -0
- package/extensions/permissions/panel/state.ts +550 -0
- package/extensions/permissions/powershell-cmdlets.ts +198 -0
- package/extensions/permissions/powershell-paths.ts +197 -0
- package/extensions/permissions/powershell-rules.ts +207 -280
- package/extensions/permissions/powershell-tree.ts +557 -0
- package/extensions/permissions/project-trust.ts +27 -11
- package/extensions/permissions/protected-paths.ts +14 -2
- package/extensions/permissions/session-grant.ts +6 -0
- package/extensions/permissions/settings.ts +166 -31
- package/extensions/permissions/subagent-gate.ts +25 -0
- package/extensions/permissions/workspace.ts +78 -0
- package/extensions/plan-mode/index.ts +156 -69
- package/extensions/plan-mode/reminder.ts +89 -21
- package/extensions/plan-mode/texts.ts +177 -0
- package/extensions/plan-mode/viewer.ts +92 -27
- package/extensions/plugins/counts.ts +3 -3
- package/extensions/plugins/index.ts +61 -46
- package/extensions/plugins/install/install.ts +16 -0
- package/extensions/plugins/marketplace/sync.ts +9 -3
- package/extensions/plugins/marketplace/types.ts +3 -1
- package/extensions/plugins/panel/keys.ts +21 -24
- package/extensions/plugins/panel/render.ts +12 -6
- package/extensions/plugins/shell-expand.ts +107 -0
- package/extensions/plugins/template.ts +32 -15
- package/extensions/powershell/child.ts +7 -0
- package/extensions/powershell/description.ts +38 -6
- package/extensions/powershell/guards.ts +1 -1
- package/extensions/powershell/index.ts +12 -7
- package/extensions/recap/index.ts +65 -60
- package/extensions/recap/prompt.ts +2 -2
- package/extensions/recap/scheduler.ts +3 -5
- package/extensions/search-tools/grep-merge.ts +39 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/send-now/chord.ts +62 -0
- package/extensions/send-now/index.ts +133 -0
- package/extensions/session-title/index.ts +3 -0
- package/extensions/session-title/title.ts +4 -1
- package/extensions/skill/code-review.ts +97 -0
- package/extensions/skill/index.ts +421 -101
- package/extensions/skill/invoke.ts +128 -18
- package/extensions/skill/listing.ts +133 -9
- package/extensions/skill/panel/keys.ts +15 -16
- package/extensions/skill/panel/render.ts +5 -2
- package/extensions/skill/panel/state.ts +12 -1
- package/extensions/subagents/agent-args.ts +39 -0
- package/extensions/subagents/agent-description.ts +132 -0
- package/extensions/subagents/agents.ts +116 -23
- package/extensions/subagents/announce.ts +69 -0
- package/extensions/subagents/default-model.ts +2 -3
- package/extensions/subagents/delegation-steer.ts +4 -4
- package/extensions/subagents/fork-cache.ts +76 -0
- package/extensions/subagents/guide-agent.ts +189 -0
- package/extensions/subagents/hand-back-review.ts +3 -0
- package/extensions/subagents/index.ts +1493 -824
- package/extensions/subagents/live-runs.ts +26 -6
- package/extensions/subagents/model-select.ts +38 -25
- package/extensions/subagents/outcome.ts +2 -2
- package/extensions/subagents/panel-keys.ts +73 -31
- package/extensions/subagents/panel-render.ts +120 -96
- package/extensions/subagents/panel-widget.ts +60 -89
- package/extensions/subagents/runner.ts +108 -80
- package/extensions/subagents/runs.ts +39 -5
- package/extensions/subagents/send-to-main-tool.ts +1 -1
- package/extensions/subagents/session-turns.ts +32 -8
- package/extensions/subagents/shell-panel.ts +36 -178
- package/extensions/subagents/tasks-dialog.ts +170 -0
- package/extensions/subagents/viewport-mirror.ts +95 -0
- package/extensions/subagents/worktree.ts +57 -13
- package/extensions/system-prompt/environment.ts +19 -19
- package/extensions/system-prompt/idle-turn.ts +65 -0
- package/extensions/system-prompt/index.ts +131 -47
- package/extensions/system-prompt/template.ts +24 -50
- package/extensions/system-prompt/tiers/common.ts +23 -64
- package/extensions/system-prompt/tiers/frontier.ts +32 -11
- package/extensions/system-prompt/tiers/low.ts +67 -45
- package/extensions/system-prompt/tiers/mid.ts +96 -38
- package/extensions/system-reminder/index.ts +175 -13
- package/extensions/tasks/descriptions.ts +190 -0
- package/extensions/tasks/index.ts +120 -41
- package/extensions/tasks/store.ts +83 -16
- package/extensions/tool-call-corruption/child.ts +7 -0
- package/extensions/tool-call-corruption/index.ts +237 -0
- package/extensions/tool-search/announce.ts +16 -0
- package/extensions/tool-search/index.ts +189 -41
- package/extensions/tool-style/index.ts +68 -10
- package/extensions/tool-style/write-description.ts +40 -0
- package/extensions/tool-style/write-render.ts +237 -0
- package/extensions/turn-duration/index.ts +7 -3
- package/extensions/turn-duration/line.ts +47 -4
- package/extensions/web/backends.ts +6 -9
- package/extensions/web/description.ts +50 -0
- package/extensions/web/index.ts +118 -53
- package/extensions/web/native-call.ts +15 -0
- package/extensions/web/native-results.ts +41 -0
- package/extensions/web-fetch/description.ts +42 -0
- package/extensions/web-fetch/extract.ts +53 -6
- package/extensions/web-fetch/index.ts +125 -76
- package/extensions/web-fetch/page-content.ts +19 -0
- package/extensions/web-fetch/response.ts +49 -0
- package/extensions/web-fetch/summarize.ts +9 -10
- package/extensions/workflow/agent-session.ts +105 -18
- package/extensions/workflow/args.ts +24 -0
- package/extensions/workflow/description.ts +67 -0
- package/extensions/workflow/globals.ts +17 -3
- package/extensions/workflow/index.ts +145 -162
- package/extensions/workflow/records.ts +1 -0
- package/extensions/workflow/run-manager.ts +29 -6
- package/extensions/workflow/saved-workflows.ts +3 -2
- package/extensions/workflow/types.ts +22 -1
- package/extensions/workflow/viewer.ts +54 -24
- package/extensions/workflow/widget.ts +20 -0
- package/extensions/worktree/descriptions.ts +91 -0
- package/extensions/worktree/guards.ts +316 -54
- package/extensions/worktree/index.ts +121 -39
- package/extensions/worktree/powershell-guards.ts +167 -0
- package/extensions/worktree/rewrite.ts +14 -16
- package/package.json +18 -7
- package/skills/artifact-design/SKILL.md +85 -0
- package/skills/code-review/SKILL.md +20 -20
- package/skills/code-review/cells/arguments-and-flags.md +19 -0
- package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
- package/skills/code-review/cells/high-8-inline.md +122 -0
- package/skills/code-review/cells/low-1-pass-max4.md +27 -0
- package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
- package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
- package/skills/code-review/cells/medium-3x5-verify.md +132 -0
- package/skills/code-review/cells/minimal-prompt.md +32 -0
- package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
- package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
- package/skills/fewer-permission-prompts/SKILL.md +1 -1
- package/skills/loop/SKILL.md +11 -0
- package/skills/security-review/SKILL.md +2 -5
- package/skills/simplify/SKILL.md +7 -5
- package/skills/workflow-authoring/SKILL.md +143 -0
- package/themes/onecode-light.json +1 -1
- package/themes/onecode.json +1 -1
- package/types/app.d.ts +58 -0
- package/types/community-packages.d.ts +26 -1
- package/extensions/auto-mode/recoverability.ts +0 -229
- package/extensions/lib/capability-index.ts +0 -466
- package/extensions/lib/model-facts.generated.json +0 -2675
- package/extensions/lib/model-facts.ts +0 -162
|
@@ -32,7 +32,24 @@
|
|
|
32
32
|
|
|
33
33
|
import { isProtectedPath } from "../permissions/protected-paths.ts";
|
|
34
34
|
import { isExecutionPrimitivePath, isSensitivePath } from "./sensitive.ts";
|
|
35
|
-
import { isWithin, resolveForContainment, toAbsoluteBash } from "./paths.ts";
|
|
35
|
+
import { isWithin, resolveForContainment, toAbsoluteBash, touchesGuardedPath } from "./paths.ts";
|
|
36
|
+
import { checkoutGitRunsProgram, READ_HOOKS } from "./git-checkout-programs.ts";
|
|
37
|
+
import { lstatSync, readdirSync, statSync } from "node:fs";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
import {
|
|
40
|
+
GIT_GLOBAL_SAFE,
|
|
41
|
+
GIT_SUBCOMMAND_SPECS,
|
|
42
|
+
PATTERN_OPTIONS,
|
|
43
|
+
READ_ONLY_SPECS,
|
|
44
|
+
checkFind,
|
|
45
|
+
checkOptions,
|
|
46
|
+
gitOperandsAllowed,
|
|
47
|
+
table,
|
|
48
|
+
type OptionSpec,
|
|
49
|
+
} from "./read-only-options.ts";
|
|
50
|
+
import { parseCommand, scopedTracker, type Segment, type Token } from "./shell-parse.ts";
|
|
51
|
+
|
|
52
|
+
export { decodeAnsiC, LOOPS, parseCommand, scopedTracker, type ParseResult, type Segment, type Token } from "./shell-parse.ts";
|
|
36
53
|
|
|
37
54
|
export type ShellVerdict = "safe" | "escalate";
|
|
38
55
|
|
|
@@ -79,74 +96,33 @@ export interface ShellEvidence {
|
|
|
79
96
|
*/
|
|
80
97
|
readOnlyOutside: boolean;
|
|
81
98
|
/**
|
|
82
|
-
* True when
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* clear the command (see auto-mode/recoverability.ts). Never true when the
|
|
88
|
-
* verdict is "safe" (nothing escalated) — it only qualifies an escalation.
|
|
99
|
+
* True when every reason this escalated is a read the classifier should
|
|
100
|
+
* see: an outside-cwd read, or a read that names a credential or an
|
|
101
|
+
* execution-primitive path (`find -name '*.pem'`, `cat Makefile`). No write,
|
|
102
|
+
* mutation, network, unknown command or unmodelled syntax, so nothing here
|
|
103
|
+
* can write a file. Never true for a "safe" verdict.
|
|
89
104
|
*/
|
|
90
|
-
|
|
105
|
+
readsOnly: boolean;
|
|
91
106
|
/**
|
|
92
|
-
* True
|
|
93
|
-
*
|
|
94
|
-
*
|
|
107
|
+
* True when the ONLY reason this escalated is Claude Code's acceptEdits file
|
|
108
|
+
* commands (`ACCEPT_EDITS_OPTIONS`) on the working space — every path is
|
|
109
|
+
* inside the working directory (or a `writableRoots` entry) and resolved,
|
|
110
|
+
* nothing touches the network, no credential/execution-primitive path, no
|
|
111
|
+
* unknown command, interpreter, glob, xargs, `cd`, or unmodelled syntax.
|
|
112
|
+
* Never true when the verdict is "safe" (nothing escalated) — it only
|
|
113
|
+
* qualifies an escalation.
|
|
95
114
|
*/
|
|
96
|
-
|
|
115
|
+
containedNonNetwork: boolean;
|
|
97
116
|
}
|
|
98
117
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"date",
|
|
108
|
-
"diff",
|
|
109
|
-
"dirname",
|
|
110
|
-
"du",
|
|
111
|
-
"echo",
|
|
112
|
-
"false",
|
|
113
|
-
"file",
|
|
114
|
-
"fold",
|
|
115
|
-
"head",
|
|
116
|
-
"hostname",
|
|
117
|
-
"id",
|
|
118
|
-
"join",
|
|
119
|
-
"jq",
|
|
120
|
-
"ls",
|
|
121
|
-
"md5sum",
|
|
122
|
-
"nl",
|
|
123
|
-
"od",
|
|
124
|
-
"paste",
|
|
125
|
-
// `printenv` and a bare `env` are NOT read-only for this purpose: they dump
|
|
126
|
-
// the process environment, provider keys included (Claude Code removed both
|
|
127
|
-
// from its read-only list for the same reason).
|
|
128
|
-
"printf",
|
|
129
|
-
"pwd",
|
|
130
|
-
"readlink",
|
|
131
|
-
"realpath",
|
|
132
|
-
"rev",
|
|
133
|
-
"rg",
|
|
134
|
-
"sha1sum",
|
|
135
|
-
"sha256sum",
|
|
136
|
-
"shasum",
|
|
137
|
-
"sort",
|
|
138
|
-
"stat",
|
|
139
|
-
"tail",
|
|
140
|
-
"tr",
|
|
141
|
-
"tree",
|
|
142
|
-
"true",
|
|
143
|
-
"uname",
|
|
144
|
-
"uniq",
|
|
145
|
-
"wc",
|
|
146
|
-
"which",
|
|
147
|
-
"whoami",
|
|
148
|
-
"yes",
|
|
149
|
-
]);
|
|
118
|
+
/**
|
|
119
|
+
* Commands that read and do not write, and are safe to fast-path — each only
|
|
120
|
+
* with the options its table in read-only-options.ts names. `printenv` and a
|
|
121
|
+
* bare `env` are not here: they dump the process environment, provider keys
|
|
122
|
+
* included (Claude Code removed both from its read-only list for the same
|
|
123
|
+
* reason).
|
|
124
|
+
*/
|
|
125
|
+
const READ_ONLY_COMMANDS = new Set(Object.keys(READ_ONLY_SPECS));
|
|
150
126
|
|
|
151
127
|
/**
|
|
152
128
|
* Read-only commands that take no file operands, so a path-looking positional
|
|
@@ -174,32 +150,83 @@ const NO_FILE_OPERANDS = new Set([
|
|
|
174
150
|
/**
|
|
175
151
|
* `grep` and friends take a pattern before their paths, so the first positional
|
|
176
152
|
* is not a path token. Tracked separately to avoid classifying a regex as a file.
|
|
153
|
+
* `ag` and `ack` are not fast-pathed at all: both take a pager program, and ack
|
|
154
|
+
* reads a project `.ackrc` this check never sees.
|
|
177
155
|
*/
|
|
178
|
-
const PATTERN_FIRST_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg"
|
|
156
|
+
const PATTERN_FIRST_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg"]);
|
|
157
|
+
|
|
158
|
+
interface WrapperSpec {
|
|
159
|
+
/** Options whose next word is a harmless value, skipped in both readings. */
|
|
160
|
+
values?: readonly string[];
|
|
161
|
+
/**
|
|
162
|
+
* Options whose value changes where or how the payload runs (`env -C`,
|
|
163
|
+
* `time -o`, `script -T`). The strict reading leaves the value in command
|
|
164
|
+
* position, so the command escalates; the wide reading skips it.
|
|
165
|
+
*/
|
|
166
|
+
unsafeValues?: readonly string[];
|
|
167
|
+
/** Options whose value is a command line of its own (`flock -c`, `env -S`): the wide reading returns it. */
|
|
168
|
+
scripts?: readonly string[];
|
|
169
|
+
/** The first operand is a file, not the command (`flock LOCK cmd`, `script LOG cmd`). */
|
|
170
|
+
fileFirst?: boolean;
|
|
171
|
+
/** The first operand is a duration (`timeout 30 cmd`). */
|
|
172
|
+
durationFirst?: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* Whether an in-project payload stays contained (`containedNonNetwork`).
|
|
175
|
+
* xargs takes its targets from stdin, and flock and script write the
|
|
176
|
+
* file they are given, which nothing write-checks.
|
|
177
|
+
*/
|
|
178
|
+
contained: boolean;
|
|
179
|
+
/** Peeled only for deny/ask forms; the pre-gate treats it as an unknown command. */
|
|
180
|
+
denyFormsOnly?: boolean;
|
|
181
|
+
}
|
|
179
182
|
|
|
180
183
|
/**
|
|
181
|
-
* Commands that run whatever follows them
|
|
182
|
-
*
|
|
183
|
-
* harmless `env` (review finding N5)
|
|
184
|
-
*
|
|
184
|
+
* Commands that run whatever follows them, and how each one takes its own
|
|
185
|
+
* options. The original only knew three and never looked past them, so `env
|
|
186
|
+
* rm -rf ~/Desktop` classified as a harmless `env` (review finding N5); one
|
|
187
|
+
* shared value-option list later read `setsid -c` (a flag) and `stdbuf -o 1M`
|
|
188
|
+
* wrong, so the real command slipped past deny rules
|
|
189
|
+
* (PREGATE-REVIEW-2026-09-23 P6). `sudo`/`doas` are peeled for deny forms
|
|
190
|
+
* only: the pre-gate peeling them would clear `sudo rm` as a contained delete.
|
|
185
191
|
*/
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
"
|
|
192
|
-
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
192
|
+
const WRAPPERS: Record<string, WrapperSpec> = {
|
|
193
|
+
// `builtin cd x` runs the builtin `cd` (PR #12 review: it hid a `cd` from the worktree guard).
|
|
194
|
+
builtin: { contained: true },
|
|
195
|
+
// Runs its argument as the command of that name (`busybox rm x`).
|
|
196
|
+
busybox: { contained: false, denyFormsOnly: true },
|
|
197
|
+
caffeinate: { values: ["-t", "-w"], contained: true, denyFormsOnly: true },
|
|
198
|
+
command: { contained: true },
|
|
199
|
+
doas: { values: ["-u", "-a"], unsafeValues: ["-C"], contained: false, denyFormsOnly: true },
|
|
200
|
+
exec: { values: ["-a"], contained: false, denyFormsOnly: true },
|
|
201
|
+
env: { values: ["-u", "--unset"], unsafeValues: ["-C", "--chdir"], scripts: ["-S", "--split-string"], contained: true },
|
|
202
|
+
flock: { values: ["-w", "--timeout", "-E", "--conflict-exit-code"], scripts: ["-c", "--command"], fileFirst: true, contained: false },
|
|
203
|
+
ionice: { values: ["-c", "--class", "-n", "--classdata"], contained: true },
|
|
204
|
+
nice: { values: ["-n", "--adjustment"], contained: true },
|
|
205
|
+
nohup: { contained: true },
|
|
206
|
+
script: {
|
|
207
|
+
values: ["-t", "-E", "--echo", "-m", "--logging-format"],
|
|
208
|
+
unsafeValues: ["-T", "--log-timing", "-I", "--log-in", "-O", "--log-out", "-B", "--log-io"],
|
|
209
|
+
scripts: ["-c", "--command"],
|
|
210
|
+
fileFirst: true,
|
|
211
|
+
contained: false,
|
|
212
|
+
},
|
|
213
|
+
setsid: { contained: true },
|
|
214
|
+
stdbuf: { values: ["-i", "-o", "-e", "--input", "--output", "--error"], contained: true },
|
|
215
|
+
sudo: {
|
|
216
|
+
values: ["-u", "-g", "-h", "-p", "-r", "-t", "-U", "-T", "-a", "--user", "--group", "--host", "--prompt", "--role", "--type", "--other-user", "--command-timeout", "--auth-type"],
|
|
217
|
+
unsafeValues: ["-C", "-D", "-R", "--close-from", "--chdir", "--chroot"],
|
|
218
|
+
contained: false,
|
|
219
|
+
denyFormsOnly: true,
|
|
220
|
+
},
|
|
221
|
+
time: { values: ["-f", "--format"], unsafeValues: ["-o", "--output"], contained: true },
|
|
222
|
+
timeout: { values: ["-s", "--signal", "-k", "--kill-after"], durationFirst: true, contained: true },
|
|
223
|
+
watch: { values: ["-n", "--interval", "-q", "--equexit"], contained: false, denyFormsOnly: true },
|
|
224
|
+
xargs: {
|
|
225
|
+
values: ["-I", "-L", "-n", "-P", "-s", "-E", "-d", "--delimiter", "--max-args", "--max-procs", "--max-chars", "--max-lines", "--eof"],
|
|
226
|
+
unsafeValues: ["-a", "--arg-file"],
|
|
227
|
+
contained: false,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
203
230
|
|
|
204
231
|
/**
|
|
205
232
|
* Anything here writes, deletes, or fetches-and-writes. The list is only used to
|
|
@@ -230,46 +257,44 @@ const MUTATION_COMMANDS = new Set([
|
|
|
230
257
|
"zip",
|
|
231
258
|
]);
|
|
232
259
|
|
|
260
|
+
/** The delete/truncate commands whose targets are every positional, bare names included. */
|
|
261
|
+
const DELETE_COMMANDS = new Set(["rm", "rmdir", "shred", "truncate"]);
|
|
262
|
+
|
|
233
263
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* inside the working directory and the recoverability check must pass.
|
|
264
|
+
* Claude Code's acceptEdits file commands (2.1.282, findings §36), with the
|
|
265
|
+
* options this check models; the only mutations that can be marked contained.
|
|
266
|
+
* Any other option, one carrying a value included (`cp -t dir`,
|
|
267
|
+
* `--target-directory=dir`, `mkdir -m`), leaves the command uncontained, as do
|
|
268
|
+
* symlink-following `cp -L`/`-H`. `sed` is on Claude Code's list but left out
|
|
269
|
+
* until its program is checked the way Claude Code checks it; Claude Code
|
|
270
|
+
* classified `sed -i '' …` anyway. This is an *allowlist* for the "provably
|
|
271
|
+
* contained" conclusion: omission only sends a command to the classifier.
|
|
243
272
|
*/
|
|
244
|
-
const
|
|
273
|
+
const ACCEPT_EDITS_OPTIONS: Record<string, OptionSpec> = {
|
|
274
|
+
rm: table({ none: "-f -R -r -i -v -I -d --force --recursive --verbose --dir --interactive" }),
|
|
275
|
+
rmdir: table({ none: "-p -v --parents --verbose" }),
|
|
276
|
+
mkdir: table({ none: "-p -v --parents --verbose" }),
|
|
277
|
+
touch: table({ none: "-a -c -m --no-create" }),
|
|
278
|
+
cp: table({ none: "-r -R -f -i -n -p -v -a -P --recursive --force --interactive --no-clobber --verbose --archive --no-dereference" }),
|
|
279
|
+
mv: table({ none: "-f -i -n -v --force --interactive --no-clobber --verbose" }),
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The operands of an acceptEdits file command, or undefined when it carries an
|
|
284
|
+
* option `ACCEPT_EDITS_OPTIONS` does not model. After `--` every word is an operand.
|
|
285
|
+
*/
|
|
286
|
+
function acceptEditsOperands(name: string, args: Token[]): Token[] | undefined {
|
|
287
|
+
const spec = ACCEPT_EDITS_OPTIONS[name];
|
|
288
|
+
if (!spec) return undefined;
|
|
289
|
+
const check = checkOptions(spec, args);
|
|
290
|
+
return check.ok ? check.parsed.positionals : undefined;
|
|
291
|
+
}
|
|
245
292
|
|
|
246
293
|
/** Glob/other shell metacharacters we cannot enumerate, so a target carrying one is not "concrete". */
|
|
247
294
|
function hasGlob(token: string): boolean {
|
|
248
295
|
return /[*?\[\]]/.test(token);
|
|
249
296
|
}
|
|
250
297
|
|
|
251
|
-
/** `git reset --hard [ref]` — a whole-working-tree discard of uncommitted changes. */
|
|
252
|
-
function isWholeTreeGitReset(args: Token[]): boolean {
|
|
253
|
-
const positionals = args.filter((token) => !token.value.startsWith("-"));
|
|
254
|
-
return positionals[0]?.value === "reset" && args.some((token) => token.value === "--hard");
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Whether git is being pointed at another repository or tree (`-C dir`,
|
|
259
|
-
* `--git-dir[=]…`, `--work-tree[=]…`, `--namespace`, `--exec-path`). A
|
|
260
|
-
* whole-tree op carrying one of these acts on THAT tree, so judging it against
|
|
261
|
-
* the working directory would clear a reset of some other checkout.
|
|
262
|
-
*/
|
|
263
|
-
function hasGitRetargetFlag(args: Token[]): boolean {
|
|
264
|
-
return args.some((token) => {
|
|
265
|
-
const value = token.value;
|
|
266
|
-
if (!value.startsWith("-")) return false;
|
|
267
|
-
if (GIT_GLOBAL_VALUE_FLAGS.has(value)) return true;
|
|
268
|
-
const eq = value.indexOf("=");
|
|
269
|
-
return eq > 0 && GIT_GLOBAL_VALUE_FLAGS.has(value.slice(0, eq));
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
298
|
/**
|
|
274
299
|
* Interpreters whose *scripts* can write files the token-level pass never sees
|
|
275
300
|
* (review finding N8: `awk 'BEGIN{print > "f"}'`). Their programs are not
|
|
@@ -312,32 +337,16 @@ const NETWORK_COMMANDS = new Set([
|
|
|
312
337
|
]);
|
|
313
338
|
|
|
314
339
|
/**
|
|
315
|
-
* git is **default-deny by subcommand**: only
|
|
316
|
-
* fast-pathed,
|
|
340
|
+
* git is **default-deny by subcommand and by option**: only the subcommands in
|
|
341
|
+
* `GIT_SUBCOMMAND_SPECS` (read-only-options.ts) can be fast-pathed, each only
|
|
342
|
+
* with the options its table names. The original enumerated *mutating*
|
|
317
343
|
* subcommands and allowed the rest, so `git rm`, `git mv`, `git archive`,
|
|
318
|
-
* `git config`, and `git update-ref` all slipped through (review finding N11)
|
|
319
|
-
*
|
|
320
|
-
*
|
|
344
|
+
* `git config`, and `git update-ref` all slipped through (review finding N11);
|
|
345
|
+
* until 2026-09-23 the options after an allowed subcommand were never looked
|
|
346
|
+
* at, so `git branch -D`, `git diff --output=f` and `git grep -O<prog>` passed
|
|
347
|
+
* as reads (SECURITY-REVIEW-2026-09-23 H1). `ls-remote` is not read-only: it
|
|
348
|
+
* contacts a remote.
|
|
321
349
|
*/
|
|
322
|
-
const GIT_READ_ONLY_SUBCOMMANDS = new Set([
|
|
323
|
-
"blame",
|
|
324
|
-
"branch", // listing; mutation forms carry flags, and any flag we do not model escalates
|
|
325
|
-
"cat-file",
|
|
326
|
-
"describe",
|
|
327
|
-
"diff",
|
|
328
|
-
"grep",
|
|
329
|
-
"log",
|
|
330
|
-
"ls-files",
|
|
331
|
-
"ls-remote",
|
|
332
|
-
"ls-tree",
|
|
333
|
-
"rev-parse",
|
|
334
|
-
"shortlog",
|
|
335
|
-
"show",
|
|
336
|
-
"show-ref",
|
|
337
|
-
"status",
|
|
338
|
-
"tag", // listing only, same reasoning as branch
|
|
339
|
-
]);
|
|
340
|
-
|
|
341
350
|
/**
|
|
342
351
|
* git global flags that take a value. `git -c k=v <sub>` can turn a read into
|
|
343
352
|
* code execution (`git -c protocol.ext.allow=always clone ext::sh …` — review
|
|
@@ -346,283 +355,148 @@ const GIT_READ_ONLY_SUBCOMMANDS = new Set([
|
|
|
346
355
|
*/
|
|
347
356
|
const GIT_GLOBAL_VALUE_FLAGS = new Set(["-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
|
|
348
357
|
|
|
349
|
-
export interface Token {
|
|
350
|
-
/** The token with quotes removed and `$'…'` decoded. */
|
|
351
|
-
value: string;
|
|
352
|
-
/** True when the token was written with any quoting or expansion syntax. */
|
|
353
|
-
hadExpansion: boolean;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
export interface Segment {
|
|
357
|
-
tokens: Token[];
|
|
358
|
-
/** Redirection targets found in this segment, in written order. */
|
|
359
|
-
redirects: string[];
|
|
360
|
-
raw: string;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** Decode the escapes bash understands inside `$'…'`. */
|
|
364
|
-
function decodeAnsiC(body: string): string {
|
|
365
|
-
return body.replace(/\\(n|t|r|\\|'|"|a|b|f|v|0|x[0-9a-fA-F]{1,2})/g, (match, escape: string) => {
|
|
366
|
-
switch (escape) {
|
|
367
|
-
case "n":
|
|
368
|
-
return "\n";
|
|
369
|
-
case "t":
|
|
370
|
-
return "\t";
|
|
371
|
-
case "r":
|
|
372
|
-
return "\r";
|
|
373
|
-
case "\\":
|
|
374
|
-
return "\\";
|
|
375
|
-
case "'":
|
|
376
|
-
return "'";
|
|
377
|
-
case '"':
|
|
378
|
-
return '"';
|
|
379
|
-
case "a":
|
|
380
|
-
return "\x07";
|
|
381
|
-
case "b":
|
|
382
|
-
return "\b";
|
|
383
|
-
case "f":
|
|
384
|
-
return "\f";
|
|
385
|
-
case "v":
|
|
386
|
-
return "\v";
|
|
387
|
-
case "0":
|
|
388
|
-
return "\0";
|
|
389
|
-
default:
|
|
390
|
-
return escape.startsWith("x") ? String.fromCharCode(Number.parseInt(escape.slice(1), 16)) : match;
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
358
|
/**
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
359
|
+
* Commands that only print their arguments, so a command substitution among
|
|
360
|
+
* them is safe once the substituted command is: its output is printed, not
|
|
361
|
+
* read as an option, a path or a program (`echo "built $(date)"`). `echo`'s
|
|
362
|
+
* options only change how it prints. `printf` is not here: `printf $(echo
|
|
363
|
+
* -v) PATH ./bin` assigns a variable (PR #12 review).
|
|
400
364
|
*/
|
|
401
|
-
|
|
402
|
-
if (/\$\(/.test(command)) return "uses command substitution $( )";
|
|
403
|
-
if (/(^|[^\\])`/.test(command)) return "uses backtick command substitution";
|
|
404
|
-
if (/[<>]\(/.test(command)) return "uses process substitution";
|
|
405
|
-
if (/\beval\b|\bexec\b/.test(command)) return "uses eval/exec";
|
|
406
|
-
if (/\|\s*(bash|sh|zsh|python|perl|node|ruby)\b/.test(command)) return "pipes into an interpreter";
|
|
407
|
-
if (/base64\s+(-d|--decode)/.test(command)) return "decodes base64, which can hide the real command";
|
|
408
|
-
return undefined;
|
|
409
|
-
}
|
|
365
|
+
const PURE_OUTPUT = new Set(["echo"]);
|
|
410
366
|
|
|
411
|
-
/**
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
367
|
+
/** A parameter bash sets itself (`$1`, `$@`, `$$`, `${#}`), as opposed to an environment variable. */
|
|
368
|
+
const SPECIAL_PARAMETER = /\$\{?[0-9@*#?$!-]/;
|
|
369
|
+
|
|
370
|
+
export interface Payload {
|
|
371
|
+
command: string;
|
|
372
|
+
args: Token[];
|
|
373
|
+
/** The wrappers peeled, outermost first. */
|
|
374
|
+
peeled: string[];
|
|
375
|
+
/** Command-position words spelled with a directory (`./cat`, `bin/ls`, `/bin/rm`), as written. */
|
|
376
|
+
pathNamed: string[];
|
|
377
|
+
/** Wide reading only: command lines a wrapper option runs (`flock -c '…'`, `env -S '…'`). */
|
|
378
|
+
scripts: string[];
|
|
422
379
|
}
|
|
423
380
|
|
|
424
381
|
/**
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
382
|
+
* Peel wrappers to the command that actually runs (review finding N5).
|
|
383
|
+
*
|
|
384
|
+
* The two readings fail in opposite directions. `"strict"` (the default: the
|
|
385
|
+
* pre-gate and the guards) peels only what it fully understands, so anything
|
|
386
|
+
* else stays in command position and escalates. `"wide"` (deny/ask forms)
|
|
387
|
+
* also skips the values of options that change where the payload runs,
|
|
388
|
+
* returns the command lines wrapper options carry, and peels `sudo`/`doas`,
|
|
389
|
+
* because a deny rule should see every command a line might run.
|
|
429
390
|
*/
|
|
430
|
-
export function
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
let
|
|
434
|
-
let rawStart = 0;
|
|
435
|
-
|
|
436
|
-
let current = "";
|
|
437
|
-
let hadExpansion = false;
|
|
438
|
-
let quoted = false;
|
|
439
|
-
let inSingle = false;
|
|
440
|
-
let inDouble = false;
|
|
441
|
-
let escape = false;
|
|
442
|
-
/** Set while consuming the token that follows a redirection operator. */
|
|
443
|
-
let pendingRedirect = false;
|
|
444
|
-
|
|
445
|
-
const pushToken = () => {
|
|
446
|
-
if (!current && !quoted) return;
|
|
447
|
-
if (pendingRedirect) {
|
|
448
|
-
redirects.push(current);
|
|
449
|
-
pendingRedirect = false;
|
|
450
|
-
} else {
|
|
451
|
-
tokens.push({ value: current, hadExpansion });
|
|
452
|
-
}
|
|
453
|
-
current = "";
|
|
454
|
-
hadExpansion = false;
|
|
455
|
-
quoted = false;
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
/** `at` = index of the separator; `width` = its length (`&&` is 2). */
|
|
459
|
-
const pushSegment = (at: number, width = 1) => {
|
|
460
|
-
pushToken();
|
|
461
|
-
if (tokens.length > 0 || redirects.length > 0) {
|
|
462
|
-
segments.push({ tokens, redirects, raw: command.slice(rawStart, at).trim() });
|
|
463
|
-
}
|
|
464
|
-
tokens = [];
|
|
465
|
-
redirects = [];
|
|
466
|
-
rawStart = at + width;
|
|
467
|
-
};
|
|
468
|
-
|
|
469
|
-
for (let i = 0; i < command.length; i++) {
|
|
470
|
-
const ch = command[i];
|
|
471
|
-
|
|
472
|
-
if (escape) {
|
|
473
|
-
current += ch;
|
|
474
|
-
escape = false;
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
if (ch === "\\" && !inSingle) {
|
|
478
|
-
escape = true;
|
|
479
|
-
continue;
|
|
480
|
-
}
|
|
481
|
-
if (ch === "'" && !inDouble) {
|
|
482
|
-
inSingle = !inSingle;
|
|
483
|
-
quoted = true;
|
|
484
|
-
continue;
|
|
485
|
-
}
|
|
486
|
-
if (ch === '"' && !inSingle) {
|
|
487
|
-
inDouble = !inDouble;
|
|
488
|
-
quoted = true;
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
// ANSI-C quoting: `$'\x2e\x2e'` is `..`. The original kept the leading `$`,
|
|
492
|
-
// which made the token look like a variable and skipped every path check
|
|
493
|
-
// (review finding N1, reproduced there).
|
|
494
|
-
if (ch === "$" && command[i + 1] === "'" && !inSingle && !inDouble) {
|
|
495
|
-
const end = command.indexOf("'", i + 2);
|
|
496
|
-
if (end === -1) return { segments: [], parseFailed: true };
|
|
497
|
-
current += decodeAnsiC(command.slice(i + 2, end));
|
|
498
|
-
quoted = true;
|
|
499
|
-
hadExpansion = true;
|
|
500
|
-
i = end;
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
if (inSingle || inDouble) {
|
|
505
|
-
current += ch;
|
|
506
|
-
continue;
|
|
507
|
-
}
|
|
391
|
+
export function resolvePayload(tokens: Token[], reading: "strict" | "wide" = "strict"): Payload {
|
|
392
|
+
const wide = reading === "wide";
|
|
393
|
+
const payload: Payload = { command: "", args: [], peeled: [], pathNamed: [], scripts: [] };
|
|
394
|
+
let index = 0;
|
|
508
395
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
// `npm test x\ncurl evil` would be one segment whose lead is `npm test`.
|
|
512
|
-
pushSegment(i);
|
|
513
|
-
continue;
|
|
514
|
-
}
|
|
515
|
-
if (/\s/.test(ch)) {
|
|
516
|
-
pushToken();
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
396
|
+
const kind = (spec: WrapperSpec, option: string) =>
|
|
397
|
+
spec.values?.includes(option) ? "value" : spec.unsafeValues?.includes(option) ? "unsafe" : spec.scripts?.includes(option) ? "script" : undefined;
|
|
519
398
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Skip the wrapper's own options; `--` ends them. A value may be attached,
|
|
401
|
+
* as getopt allows: `--chdir=/tmp`, `-C/tmp`, or last in a short cluster
|
|
402
|
+
* (`-iC/tmp`). Until 2026-09-24 only the separate spelling was recognised,
|
|
403
|
+
* so `env --chdir=/tmp rm -f a.txt` peeled as a contained delete and
|
|
404
|
+
* `env --split-string='rm …'` hid its script from deny rules.
|
|
405
|
+
*/
|
|
406
|
+
const skipOptions = (spec: WrapperSpec) => {
|
|
407
|
+
while (index < tokens.length && tokens[index].value.startsWith("-")) {
|
|
408
|
+
const word = tokens[index].value;
|
|
409
|
+
if (word === "--") {
|
|
410
|
+
index++;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
let option: string | undefined;
|
|
414
|
+
let attached: string | undefined;
|
|
415
|
+
if (word.startsWith("--")) {
|
|
416
|
+
const eq = word.indexOf("=");
|
|
417
|
+
option = eq > 0 ? word.slice(0, eq) : word;
|
|
418
|
+
attached = eq > 0 ? word.slice(eq + 1) : undefined;
|
|
419
|
+
} else {
|
|
420
|
+
for (let i = 1; i < word.length; i++) {
|
|
421
|
+
if (!kind(spec, `-${word[i]}`)) continue;
|
|
422
|
+
option = `-${word[i]}`;
|
|
423
|
+
attached = word.slice(i + 1) || undefined;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const type = option === undefined ? undefined : kind(spec, option);
|
|
428
|
+
// Strict: an attached value that moves the payload or carries a script
|
|
429
|
+
// stays in command position, so the command escalates uncontained.
|
|
430
|
+
if (!wide && attached !== undefined && (type === "unsafe" || type === "script")) return;
|
|
431
|
+
index++;
|
|
432
|
+
if (type === undefined || attached !== undefined) {
|
|
433
|
+
if (wide && type === "script" && attached !== undefined) payload.scripts.push(attached);
|
|
531
434
|
continue;
|
|
532
435
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
436
|
+
if (index >= tokens.length) return;
|
|
437
|
+
if (type === "value") index++;
|
|
438
|
+
else if (wide && type === "unsafe") index++;
|
|
439
|
+
else if (wide && type === "script") payload.scripts.push(tokens[index++].value);
|
|
536
440
|
}
|
|
537
|
-
|
|
538
|
-
if (ch === "|" || ch === ";" || ch === "&") {
|
|
539
|
-
// `&&`, `||`, `;`, `|`, `&` all end a segment. Any of them means the
|
|
540
|
-
// next command is separate, which is all we need to know.
|
|
541
|
-
const doubled = command[i + 1] === ch;
|
|
542
|
-
pushSegment(i, doubled ? 2 : 1);
|
|
543
|
-
if (doubled) i++;
|
|
544
|
-
continue;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
current += ch;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
if (escape || inSingle || inDouble) return { segments: [], parseFailed: true };
|
|
551
|
-
pushSegment(command.length);
|
|
552
|
-
return { segments, parseFailed: false };
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/** Peel wrappers to the command that actually runs (review finding N5). */
|
|
556
|
-
export function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peeled: string[] } {
|
|
557
|
-
const peeled: string[] = [];
|
|
558
|
-
let index = 0;
|
|
441
|
+
};
|
|
559
442
|
|
|
560
443
|
for (;;) {
|
|
561
444
|
// Leading `VAR=value` assignments are not the command.
|
|
562
445
|
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index].value)) index++;
|
|
563
|
-
if (index >= tokens.length) return
|
|
446
|
+
if (index >= tokens.length) return payload;
|
|
564
447
|
|
|
448
|
+
if (/[\\/]/.test(tokens[index].value)) payload.pathNamed.push(tokens[index].value);
|
|
565
449
|
const name = commandName(tokens[index].value);
|
|
566
|
-
|
|
567
|
-
|
|
450
|
+
const spec = WRAPPERS[name];
|
|
451
|
+
if (!spec || (spec.denyFormsOnly && !wide)) {
|
|
452
|
+
payload.command = name;
|
|
453
|
+
payload.args = tokens.slice(index + 1);
|
|
454
|
+
return payload;
|
|
568
455
|
}
|
|
569
456
|
|
|
570
|
-
peeled.push(name);
|
|
457
|
+
payload.peeled.push(name);
|
|
571
458
|
index++;
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const flag = tokens[index].value;
|
|
459
|
+
skipOptions(spec);
|
|
460
|
+
if (spec.fileFirst && index < tokens.length) {
|
|
575
461
|
index++;
|
|
576
|
-
|
|
462
|
+
// `flock LOCK -c '…'`: options may follow the file too.
|
|
463
|
+
skipOptions(spec);
|
|
577
464
|
}
|
|
578
|
-
|
|
579
|
-
while (index < tokens.length && /^[0-9]+[smhd]?$/.test(tokens[index].value)) index++;
|
|
465
|
+
if (spec.durationFirst && index < tokens.length && /^[0-9.]+[smhd]?$/.test(tokens[index].value)) index++;
|
|
580
466
|
}
|
|
581
467
|
}
|
|
582
468
|
|
|
469
|
+
/** Whether every peeled wrapper keeps an in-project payload contained (see `WrapperSpec.contained`). */
|
|
470
|
+
function wrappersContained(peeled: readonly string[]): boolean {
|
|
471
|
+
return peeled.every((name) => WRAPPERS[name]?.contained);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Shells whose `-c` argument is a command line of its own. */
|
|
475
|
+
export const INLINE_SCRIPT_SHELLS: ReadonlySet<string> = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish"]);
|
|
476
|
+
|
|
477
|
+
/** Commands that change the shell's working directory. */
|
|
478
|
+
export const DIRECTORY_MOVERS: ReadonlySet<string> = new Set(["cd", "pushd", "popd"]);
|
|
479
|
+
|
|
480
|
+
/** Whether a segment's command (past `time` and wrappers) changes the shell's directory. */
|
|
481
|
+
export function movesDirectory(seg: Segment): boolean {
|
|
482
|
+
return DIRECTORY_MOVERS.has(resolvePayload(leadTokens(seg)).command);
|
|
483
|
+
}
|
|
484
|
+
|
|
583
485
|
/**
|
|
584
|
-
* A segment's tokens
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
* a known limitation — the closer is only balanced within one segment.
|
|
591
|
-
* Used by the bash/worktree guard pipelines, not by the escalation analyzer.
|
|
486
|
+
* A segment's tokens from its command word on, for command-position checks.
|
|
487
|
+
* `time` is a reserved word that the grammar reads as a command, so `time
|
|
488
|
+
* { rm x; }` arrives as the words `time { rm x` (and a `}` command): a leading
|
|
489
|
+
* `time`, its `-p`, and the `{`, `(` or `!` it times are skipped. The grammar
|
|
490
|
+
* has already taken that punctuation out of every other command. Used by the
|
|
491
|
+
* guards and the deny forms, not by the escalation analyzer.
|
|
592
492
|
*/
|
|
593
493
|
export function leadTokens(seg: Segment): Token[] {
|
|
594
|
-
const tokens = [...seg.tokens];
|
|
595
494
|
let i = 0;
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
while (
|
|
599
|
-
const value = tokens[i].value;
|
|
600
|
-
const lead = /^[({]+/.exec(value)?.[0] ?? "";
|
|
601
|
-
if (lead.length === 0) break;
|
|
602
|
-
opened += (lead.match(/\(/g) ?? []).length;
|
|
603
|
-
const stripped = value.slice(lead.length);
|
|
604
|
-
if (stripped.length === 0) {
|
|
605
|
-
i++;
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
608
|
-
tokens[i] = { ...tokens[i], value: stripped };
|
|
609
|
-
break;
|
|
610
|
-
}
|
|
611
|
-
if (i < tokens.length && ["do", "then", "else"].includes(tokens[i].value)) {
|
|
612
|
-
i++;
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
break;
|
|
495
|
+
while (seg.tokens[i]?.value === "time") {
|
|
496
|
+
i++;
|
|
497
|
+
while (["-p", "--", "{", "(", "!"].includes(seg.tokens[i]?.value)) i++;
|
|
616
498
|
}
|
|
617
|
-
|
|
618
|
-
const last = tokens.length - 1;
|
|
619
|
-
const trailing = /\)+$/.exec(tokens[last].value)?.[0];
|
|
620
|
-
if (trailing) {
|
|
621
|
-
const strip = Math.min(trailing.length, opened);
|
|
622
|
-
tokens[last] = { ...tokens[last], value: tokens[last].value.slice(0, tokens[last].value.length - strip) };
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
return tokens.slice(i);
|
|
499
|
+
return seg.tokens.slice(i);
|
|
626
500
|
}
|
|
627
501
|
|
|
628
502
|
/**
|
|
@@ -664,11 +538,137 @@ function looksLikePath(value: string): boolean {
|
|
|
664
538
|
return value.startsWith("/") || value.includes("/");
|
|
665
539
|
}
|
|
666
540
|
|
|
667
|
-
/**
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
541
|
+
/** Leading assignments that change nothing a command runs or reads: locale and display only. */
|
|
542
|
+
const INERT_ASSIGNMENTS = /^(LANG|LANGUAGE|LC_[A-Z]+|TZ|NO_COLOR|FORCE_COLOR|CLICOLOR|CLICOLOR_FORCE|TERM|COLUMNS|LINES)$/;
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Whether an inert variable's VALUE keeps it inert. libc reads a file named by
|
|
546
|
+
* `TZ=:/path` or `TZ=/path`, and a locale name with a `/` is a path too, so
|
|
547
|
+
* only plain names pass: a zone name made of plain segments (`Asia/Tokyo`,
|
|
548
|
+
* read from the system zoneinfo) for TZ, and no `/` at all for the rest
|
|
549
|
+
* (PREGATE-REVIEW-2026-09-23 P4).
|
|
550
|
+
*/
|
|
551
|
+
function isInertValue(name: string, value: string): boolean {
|
|
552
|
+
if (name === "TZ") return /^([A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*)?$/.test(value);
|
|
553
|
+
return /^[A-Za-z0-9_.,@:+-]*$/.test(value);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* A `~` word other than `~` and `~/…`: bash expands `~name` to that user's
|
|
558
|
+
* home and `~-`/`~+` to `$OLDPWD`/`$PWD`, which `toAbsolute` would read as a
|
|
559
|
+
* directory literally named `~name` inside the working directory.
|
|
560
|
+
*/
|
|
561
|
+
export function isUnknownTilde(value: string): boolean {
|
|
562
|
+
return value.startsWith("~") && value !== "~" && !value.startsWith("~/");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The paths a shell write must never land on, or take with it when it
|
|
567
|
+
* removes or moves a directory: protected tooling and agent configuration,
|
|
568
|
+
* the harness's `protectedDirs`, and credential and execution-primitive
|
|
569
|
+
* paths. Shared by the bash gate and the PowerShell gate (powershell-tree.ts).
|
|
570
|
+
*/
|
|
571
|
+
export function isGuardedWritePath(path: string, cwd: string, protectedDirs: readonly string[] = []): boolean {
|
|
572
|
+
return isProtectedPath(path, cwd) || protectedDirs.some((dir) => isWithin(dir, path)) || isSensitivePath(path) || isExecutionPrimitivePath(path);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Whether removing or moving away `resolved` (spelled `absolute`) loses what
|
|
577
|
+
* git or the gate would keep: a working root, a `.git`, a directory holding
|
|
578
|
+
* one at any depth, or a guarded path or a directory holding one (`rm -rf .`,
|
|
579
|
+
* `mv .git x`, `rm -rf vendor` over `vendor/lib/.git`, `rm -rf .claude`).
|
|
580
|
+
*/
|
|
581
|
+
export function removalLoses(absolute: string, resolved: string, roots: readonly string[], cwd: string, protectedDirs: readonly string[] = []): boolean {
|
|
582
|
+
const lost = (path: string) => path.split(/[\\/]/).includes(".git") || isGuardedWritePath(path, cwd, protectedDirs);
|
|
583
|
+
return roots.includes(resolved) || lost(absolute) || lost(resolved) || touchesGuardedPath(resolved, resolved, lost);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The last component of a bash path word, trailing slashes dropped (`a/b/` is `b`). */
|
|
587
|
+
function bashBasename(word: string): string {
|
|
588
|
+
const trimmed = word.replace(/\/+$/, "");
|
|
589
|
+
return trimmed.slice(trimmed.lastIndexOf("/") + 1);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Whether a directory entry exists at `absolute` (a dangling symlink counts). */
|
|
593
|
+
function entryExists(absolute: string): boolean {
|
|
594
|
+
try {
|
|
595
|
+
lstatSync(absolute);
|
|
596
|
+
return true;
|
|
597
|
+
} catch {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Whether `resolved` (already realpath'd) is a directory. */
|
|
603
|
+
function isDirectory(resolved: string): boolean {
|
|
604
|
+
try {
|
|
605
|
+
return statSync(resolved).isDirectory();
|
|
606
|
+
} catch {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const FOLLOWS_DIR_ENTRY_SYMLINKS = "reads through symbolic links inside it, which can read outside the working directory";
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* A bash glob (one path component) as a RegExp: `*`, `?`, `[…]`/`[!…]`,
|
|
615
|
+
* everything else literal. Undefined when JavaScript cannot compile the
|
|
616
|
+
* bracket expression (`[z-a]`), which bash accepts and matches nothing with.
|
|
617
|
+
*/
|
|
618
|
+
export function globComponentRegex(glob: string): RegExp | undefined {
|
|
619
|
+
let out = "";
|
|
620
|
+
for (let i = 0; i < glob.length; i++) {
|
|
621
|
+
const ch = glob[i];
|
|
622
|
+
if (ch === "*") out += "[^/]*";
|
|
623
|
+
else if (ch === "?") out += "[^/]";
|
|
624
|
+
else if (ch === "[") {
|
|
625
|
+
// A `]` first in the set, after any `!`/`^`, is a member, not the end.
|
|
626
|
+
const negation = glob[i + 1] === "!" || glob[i + 1] === "^" ? 1 : 0;
|
|
627
|
+
const close = glob.indexOf("]", i + 2 + negation);
|
|
628
|
+
if (close === -1) {
|
|
629
|
+
out += "\\[";
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
let body = glob.slice(i + 1, close);
|
|
633
|
+
const negated = body.startsWith("!") || body.startsWith("^");
|
|
634
|
+
if (negated) body = body.slice(1);
|
|
635
|
+
out += `[${negated ? "^" : ""}${body.replace(/[\\\]]/g, "\\$&")}]`;
|
|
636
|
+
i = close;
|
|
637
|
+
} else out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
return new RegExp(`^${out}$`);
|
|
641
|
+
} catch {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The words bash expands a glob operand to, or undefined when the check cannot
|
|
648
|
+
* enumerate them: a glob in a directory component, a component starting with
|
|
649
|
+
* `.` (bash before 5.2 matches `.` and `..` with `.*` or `.?`), a bracket
|
|
650
|
+
* expression JavaScript cannot compile, or more than 2,000 matches. Only the last component is expanded, as bash would with
|
|
651
|
+
* `dotglob`, `globstar` and `nocaseglob` off (their non-interactive defaults).
|
|
652
|
+
* No match leaves the word as written, as bash does without `nullglob`.
|
|
653
|
+
*/
|
|
654
|
+
function expandGlob(cwd: string, pattern: string, home: string): string[] | undefined {
|
|
655
|
+
const slash = pattern.lastIndexOf("/");
|
|
656
|
+
const dirPart = slash >= 0 ? pattern.slice(0, slash) : "";
|
|
657
|
+
const leaf = slash >= 0 ? pattern.slice(slash + 1) : pattern;
|
|
658
|
+
if (!leaf || /[*?[]/.test(dirPart) || leaf.startsWith(".")) return undefined;
|
|
659
|
+
if (isUnknownTilde(dirPart)) return undefined;
|
|
660
|
+
let entries: string[];
|
|
661
|
+
try {
|
|
662
|
+
entries = readdirSync(toAbsoluteBash(cwd, slash >= 0 ? dirPart || "/" : ".", home));
|
|
663
|
+
} catch {
|
|
664
|
+
return [pattern];
|
|
665
|
+
}
|
|
666
|
+
const regex = globComponentRegex(leaf);
|
|
667
|
+
if (!regex) return undefined;
|
|
668
|
+
const matches = entries.filter((entry) => !entry.startsWith(".") && regex.test(entry));
|
|
669
|
+
if (matches.length > 2_000) return undefined;
|
|
670
|
+
if (matches.length === 0) return [pattern];
|
|
671
|
+
return matches.map((entry) => (slash >= 0 ? `${dirPart}/${entry}` : entry));
|
|
672
672
|
}
|
|
673
673
|
|
|
674
674
|
/** The file named by a `-o FILE` / `-oFILE` / `--output=FILE` flag, if present. */
|
|
@@ -683,41 +683,103 @@ function outputFlagTarget(args: Token[]): string | undefined {
|
|
|
683
683
|
}
|
|
684
684
|
|
|
685
685
|
/**
|
|
686
|
-
* Decide git: only an explicitly read-only subcommand
|
|
687
|
-
*
|
|
686
|
+
* Decide git: only an explicitly read-only subcommand, preceded by global
|
|
687
|
+
* options from `GIT_GLOBAL_SAFE` or an in-project `-C`, and carrying only the
|
|
688
|
+
* options its table names, is safe. Returns undefined when safe, else the
|
|
689
|
+
* reason to escalate.
|
|
688
690
|
*/
|
|
689
|
-
function gitEscalationReason(args: Token[], isDirOutsideCwd: (dir: string) => boolean): string | undefined {
|
|
691
|
+
function gitEscalationReason(args: Token[], isDirOutsideCwd: (dir: string) => boolean, onFileReads: (words: Token[]) => void): string | undefined {
|
|
690
692
|
let index = 0;
|
|
691
693
|
while (index < args.length) {
|
|
692
694
|
const token = args[index].value;
|
|
693
695
|
if (!token.startsWith("-")) break;
|
|
694
|
-
// `-c key=value` can reconfigure git into executing an arbitrary helper.
|
|
695
|
-
if (token === "-c" || token.startsWith("-c
|
|
696
|
+
// `-c key=value` / `--config-env` can reconfigure git into executing an arbitrary helper.
|
|
697
|
+
if (token === "-c" || token.startsWith("-c") || token === "--config-env" || token.startsWith("--config-env=")) {
|
|
696
698
|
return "passes git -c/--config-env, which can turn a read into code execution";
|
|
697
699
|
}
|
|
698
|
-
|
|
700
|
+
if (token === "-C") {
|
|
701
|
+
const dir = args[index + 1]?.value;
|
|
702
|
+
// `-C` retargets git at another directory; outside the working directory
|
|
703
|
+
// the operation is no longer provably in-project (was review gap: `git
|
|
704
|
+
// -C /etc status` classified safe).
|
|
705
|
+
if (!dir || isDirOutsideCwd(dir)) return `runs git -C ${dir ?? ""}, which points outside the working directory`;
|
|
706
|
+
index += 2;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (GIT_GLOBAL_SAFE.has(token)) {
|
|
710
|
+
index++;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
// `--git-dir`, `--work-tree`, `--exec-path`, `--namespace` and every other
|
|
714
|
+
// global option can point git at a repository or configuration this check
|
|
715
|
+
// has not seen, in-project or not (SECURITY-REVIEW-2026-09-23 H2).
|
|
699
716
|
const eq = token.indexOf("=");
|
|
700
717
|
const flag = eq > 0 ? token.slice(0, eq) : token;
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
// directory escapes the working directory the operation is no longer
|
|
705
|
-
// provably in-project, so escalate rather than skipping the flag blindly
|
|
706
|
-
// (was review gap: `git -C /etc status` classified safe).
|
|
707
|
-
if ((flag === "-C" || flag === "--git-dir" || flag === "--work-tree") && value && isDirOutsideCwd(value)) {
|
|
708
|
-
return `runs git ${flag} ${value}, which points outside the working directory`;
|
|
709
|
-
}
|
|
710
|
-
index += eq > 0 ? 1 : 2;
|
|
711
|
-
continue;
|
|
718
|
+
const value = eq > 0 ? token.slice(eq + 1) : undefined;
|
|
719
|
+
if ((flag === "--git-dir" || flag === "--work-tree") && value !== undefined && isDirOutsideCwd(value)) {
|
|
720
|
+
return `runs git ${flag} ${value}, which points outside the working directory`;
|
|
712
721
|
}
|
|
713
|
-
|
|
722
|
+
return `passes git ${flag}, which can point git at another repository or configuration`;
|
|
714
723
|
}
|
|
715
|
-
const subcommand = args[index]?.value
|
|
724
|
+
const subcommand = args[index]?.value;
|
|
716
725
|
if (!subcommand) return undefined; // bare `git` prints help
|
|
717
|
-
|
|
726
|
+
const spec = GIT_SUBCOMMAND_SPECS[subcommand];
|
|
727
|
+
if (!spec) return `runs git ${subcommand}, which is not a read-only subcommand`;
|
|
728
|
+
const check = checkOptions(spec, args.slice(index + 1));
|
|
729
|
+
if (!check.ok) return `runs git ${subcommand} and ${check.reason}`;
|
|
730
|
+
if (!gitOperandsAllowed(subcommand, check.parsed)) {
|
|
731
|
+
return `runs git ${subcommand} with an operand and no --list, which creates a ref`;
|
|
732
|
+
}
|
|
733
|
+
if (GIT_FILE_OPERANDS.has(subcommand)) onFileReads(check.parsed.positionals);
|
|
718
734
|
return undefined;
|
|
719
735
|
}
|
|
720
736
|
|
|
737
|
+
/** The directory git runs in: `cwd`, then each leading `-C <dir>` in turn, as git applies them. */
|
|
738
|
+
function gitWorkingDirectory(args: Token[], cwd: string, home: string): string {
|
|
739
|
+
let dir = cwd;
|
|
740
|
+
for (let index = 0; index < args.length; ) {
|
|
741
|
+
const token = args[index].value;
|
|
742
|
+
if (token === "-C") {
|
|
743
|
+
const next = args[index + 1]?.value;
|
|
744
|
+
if (next) dir = toAbsoluteBash(dir, next, home);
|
|
745
|
+
index += 2;
|
|
746
|
+
} else if (GIT_GLOBAL_SAFE.has(token)) {
|
|
747
|
+
index++;
|
|
748
|
+
} else {
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return dir;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Subcommands whose operands can be files git reads outside the repository.
|
|
757
|
+
* `git diff <path> <path>` goes `--no-index` by itself when either path is
|
|
758
|
+
* outside the work tree or there is no repository, and reads both files
|
|
759
|
+
* (findings §25). Every operand is read-checked, revisions included: a
|
|
760
|
+
* revision is not a path unless a file of that name exists, and one that
|
|
761
|
+
* resolves inside the working directory passes.
|
|
762
|
+
*/
|
|
763
|
+
const GIT_FILE_OPERANDS = new Set(["diff"]);
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* For a git command the options already proved read-only: why the checkout it
|
|
767
|
+
* runs in (after every `-C`, each relative to the last) could still make it
|
|
768
|
+
* run a program (git-checkout-programs.ts), or undefined.
|
|
769
|
+
*/
|
|
770
|
+
function gitCheckoutReason(args: Token[], cwd: string, home: string, seen: Map<string, string | undefined>, hooks: readonly string[] = READ_HOOKS): string | undefined {
|
|
771
|
+
let dir = cwd;
|
|
772
|
+
for (let index = 0; index < args.length && args[index].value.startsWith("-"); index++) {
|
|
773
|
+
if (args[index].value === "-C" && args[index + 1]) dir = toAbsoluteBash(dir, args[++index].value, home);
|
|
774
|
+
}
|
|
775
|
+
// Memoized for one analysis only: nothing runs between its segments, but the
|
|
776
|
+
// model can rewrite `.git/config` between calls.
|
|
777
|
+
const key = `${hooks.join(",")}\0${dir}`;
|
|
778
|
+
if (!seen.has(key)) seen.set(key, checkoutGitRunsProgram(dir, home, hooks));
|
|
779
|
+
const why = seen.get(key);
|
|
780
|
+
return why && `runs git where ${why}`;
|
|
781
|
+
}
|
|
782
|
+
|
|
721
783
|
export interface AnalyzeInput {
|
|
722
784
|
command: string;
|
|
723
785
|
cwd: string;
|
|
@@ -737,12 +799,36 @@ export interface AnalyzeInput {
|
|
|
737
799
|
* only; the write and delete checks never consult this list.
|
|
738
800
|
*/
|
|
739
801
|
readableRoots?: string[];
|
|
802
|
+
/**
|
|
803
|
+
* REALPATH-resolved directories a write may land in besides the working
|
|
804
|
+
* directory without leaving the working space: the workspace directories,
|
|
805
|
+
* passed only for a model that gets Claude Code's fast paths
|
|
806
|
+
* (permissions/matcher.ts `DecideInput.claudeCodeFastPaths`).
|
|
807
|
+
*/
|
|
808
|
+
writableRoots?: string[];
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Whether this simple command's words have the pre-gate's read-only proof.
|
|
813
|
+
* Reuse the entire analyzer, including option/operand rules and git's checkout
|
|
814
|
+
* checks, rather than treating a command name as a proof. No expansions,
|
|
815
|
+
* nested commands or stdin scripts are exempted by this narrower predicate.
|
|
816
|
+
* Redirects are NOT exempted: callers must still scan them independently.
|
|
817
|
+
* Pure apart from the analyzer's existing filesystem reads.
|
|
818
|
+
*/
|
|
819
|
+
export function hasReadOnlyShellWords(segment: Segment, cwd: string, home: string): boolean {
|
|
820
|
+
if (segment.substitution !== undefined || segment.stdin?.length || segment.tokens.some((word) => word.dynamic || word.glob)) return false;
|
|
821
|
+
const { command, peeled, pathNamed } = resolvePayload(segment.tokens);
|
|
822
|
+
if (peeled.length || pathNamed.length || !(READ_ONLY_COMMANDS.has(command) || command === "git" || command === "find")) return false;
|
|
823
|
+
const evidence = analyzeShellCommand({ command: segment.raw, cwd, home });
|
|
824
|
+
return evidence.commands.length === 1 && evidence.writes.length === 0 && (evidence.verdict === "safe" || evidence.readsOnly);
|
|
740
825
|
}
|
|
741
826
|
|
|
742
827
|
/**
|
|
743
828
|
* Classify a shell command. Never denies — see the module contract above.
|
|
744
829
|
*/
|
|
745
|
-
export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], readableRoots = [] }: AnalyzeInput): ShellEvidence {
|
|
830
|
+
export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], readableRoots = [], writableRoots = [] }: AnalyzeInput): ShellEvidence {
|
|
831
|
+
const checkoutReasons = new Map<string, string | undefined>();
|
|
746
832
|
const evidence: ShellEvidence = {
|
|
747
833
|
verdict: "safe",
|
|
748
834
|
notes: [],
|
|
@@ -754,36 +840,38 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
754
840
|
network: [],
|
|
755
841
|
outsideReads: [],
|
|
756
842
|
readOnlyOutside: false,
|
|
843
|
+
readsOnly: false,
|
|
757
844
|
containedNonNetwork: false,
|
|
758
|
-
wholeTree: false,
|
|
759
845
|
};
|
|
760
846
|
/**
|
|
761
847
|
* Set by any escalation reason that is NOT a bare in-project mutation — i.e.
|
|
762
848
|
* anything meaning the command reaches outside the project or cannot be fully
|
|
763
849
|
* accounted for. When it stays false through an escalation, the only blocker
|
|
764
|
-
* was in-project mutation
|
|
850
|
+
* was in-project mutation (`containedNonNetwork`).
|
|
765
851
|
*/
|
|
766
852
|
let uncontained = false;
|
|
767
853
|
/** Set by any escalation that is not an outside-cwd read (see readOnlyOutside). */
|
|
768
854
|
let escalatedBeyondReads = false;
|
|
769
|
-
|
|
855
|
+
/** Set by any escalation that is neither an outside-cwd read nor a read naming a guarded path (see readsOnly). */
|
|
856
|
+
let escalatedBeyondNamedReads = false;
|
|
857
|
+
const escalate = (note: string, opts?: { contained?: boolean; outsideRead?: boolean; namedRead?: boolean }) => {
|
|
770
858
|
evidence.verdict = "escalate";
|
|
771
859
|
if (!opts?.contained) uncontained = true;
|
|
772
860
|
if (!opts?.outsideRead) escalatedBeyondReads = true;
|
|
861
|
+
if (!opts?.outsideRead && !opts?.namedRead) escalatedBeyondNamedReads = true;
|
|
773
862
|
if (!evidence.notes.includes(note)) evidence.notes.push(note);
|
|
774
863
|
};
|
|
775
864
|
|
|
776
865
|
const trimmed = command.trim();
|
|
777
866
|
if (!trimmed) return { ...evidence, verdict: "escalate", notes: ["empty command"] };
|
|
778
867
|
|
|
779
|
-
const
|
|
780
|
-
if (unmodelled) escalate(unmodelled);
|
|
781
|
-
|
|
782
|
-
const { segments, parseFailed } = parseCommand(trimmed);
|
|
868
|
+
const { segments, parseFailed, unavailable, unknownQuoting, complex } = parseCommand(trimmed);
|
|
783
869
|
if (parseFailed) {
|
|
784
|
-
escalate("could not be parsed
|
|
870
|
+
escalate(unavailable ? `could not be parsed: ${unavailable}` : "could not be parsed as bash, so nothing about it is known");
|
|
785
871
|
return evidence;
|
|
786
872
|
}
|
|
873
|
+
if (unknownQuoting) escalate(unknownQuoting);
|
|
874
|
+
if (complex) escalate(complex);
|
|
787
875
|
|
|
788
876
|
/**
|
|
789
877
|
* Containment is checked against the *resolved* working directory. Write
|
|
@@ -796,6 +884,8 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
796
884
|
|
|
797
885
|
/** `cd` changes what later relative paths mean; the original never tracked it (F6/N12). */
|
|
798
886
|
let effectiveCwd = cwd;
|
|
887
|
+
/** The directory per subshell scope: a `cd` inside `$(…)` or `( … )` does not reach the parent. */
|
|
888
|
+
const cwdByScope = scopedTracker(cwd);
|
|
799
889
|
|
|
800
890
|
/**
|
|
801
891
|
* Resolve a write-target token, record it as evidence, and escalate on any
|
|
@@ -805,9 +895,16 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
805
895
|
*/
|
|
806
896
|
const checkWriteTarget = (token: string) => {
|
|
807
897
|
if (token === "/dev/null") return;
|
|
898
|
+
// A `~name`/`~-` target or a glob lands wherever bash expands it, which
|
|
899
|
+
// this check cannot know (SECURITY-REVIEW-2026-09-23 M2).
|
|
900
|
+
if (isUnknownTilde(token) || /[*?[]/.test(token)) {
|
|
901
|
+
evidence.writes.push({ token, absolute: token, outsideCwd: true });
|
|
902
|
+
escalate(`writes to ${token}, which bash expands to a path this check cannot resolve`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
808
905
|
const absolute = toAbsoluteBash(effectiveCwd, token, home);
|
|
809
906
|
const resolved = resolveForContainment(absolute);
|
|
810
|
-
const outsideCwd = resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
907
|
+
const outsideCwd = resolved === undefined || !(isWithin(containmentRoot, resolved) || writableRoots.some((root) => isWithin(root, resolved)));
|
|
811
908
|
evidence.writes.push({ token, absolute, resolved, outsideCwd });
|
|
812
909
|
if (resolved === undefined) {
|
|
813
910
|
escalate(`writes to ${token}, which could not be resolved to a real path`);
|
|
@@ -836,15 +933,104 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
836
933
|
}
|
|
837
934
|
};
|
|
838
935
|
|
|
936
|
+
/**
|
|
937
|
+
* A word a read-only command reads. It escalates when it resolves outside
|
|
938
|
+
* the working directory and the readable roots, or onto a credential path
|
|
939
|
+
* — judged where it RESOLVES, so an in-project symlink named `notes` that
|
|
940
|
+
* points at a key file is the key file (SECURITY-REVIEW-2026-09-23 M1). A
|
|
941
|
+
* `~name`/`~-` word and a glob that could reach `..` are paths bash
|
|
942
|
+
* expands to places this check cannot see (M2); an ordinary glob is
|
|
943
|
+
* expanded one level here and every match is judged.
|
|
944
|
+
*/
|
|
945
|
+
const escalateOutsideRead = (value: string, why: string) => {
|
|
946
|
+
if (!evidence.outsideReads.includes(value)) evidence.outsideReads.push(value);
|
|
947
|
+
escalate(`reads ${value}, ${why}`, { outsideRead: true });
|
|
948
|
+
};
|
|
949
|
+
// `base` is where the reading program resolves a relative word (git's
|
|
950
|
+
// final `-C` directory); bash still expands globs from the shell's cwd.
|
|
951
|
+
const checkRead = (word: { value: string; glob?: boolean }, base = effectiveCwd) => {
|
|
952
|
+
const value = word.value;
|
|
953
|
+
if (!value) return;
|
|
954
|
+
if (isUnknownTilde(value)) {
|
|
955
|
+
escalateOutsideRead(value, "which bash expands to a directory outside what this check can see");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const targets = word.glob ? expandGlob(effectiveCwd, value, home) : [value];
|
|
959
|
+
if (targets === undefined) {
|
|
960
|
+
escalateOutsideRead(value, "a glob whose matches cannot be checked");
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
for (const target of targets) {
|
|
964
|
+
const absolute = toAbsoluteBash(base, target, home);
|
|
965
|
+
if (!looksLikePath(target) && !entryExists(absolute)) continue;
|
|
966
|
+
const resolved = resolveForContainment(absolute);
|
|
967
|
+
if (resolved !== undefined && isSensitivePath(resolved) && !isSensitivePath(absolute)) {
|
|
968
|
+
if (!evidence.sensitivePaths.includes(value)) evidence.sensitivePaths.push(value);
|
|
969
|
+
escalate(`reads ${value}, which resolves to a credential or secret path`, { namedRead: true });
|
|
970
|
+
}
|
|
971
|
+
if (resolved !== undefined && (isWithin(containmentRoot, resolved) || readableRoots.some((root) => isWithin(root, resolved)))) continue;
|
|
972
|
+
escalateOutsideRead(value, "which is outside the working directory");
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
|
|
839
976
|
for (const segment of segments) {
|
|
977
|
+
effectiveCwd = cwdByScope.get(segment);
|
|
840
978
|
// Redirection targets are writes regardless of the command word: a bare
|
|
841
979
|
// `> file` truncates/creates it with no command at all, and `git log > file`
|
|
842
980
|
// writes it too. Check them first so the command-specific `continue`s below
|
|
843
981
|
// (cd/git) can never skip a redirect (was review gap: bare-redirect writes and
|
|
844
982
|
// read-only-command redirects were fast-pathed to "safe").
|
|
845
983
|
for (const token of segment.redirects) checkWriteTarget(token);
|
|
984
|
+
// An input redirect (`tr a b < f`) opens `f` whatever the command is, so
|
|
985
|
+
// it is read-checked even for a command that takes no file operands. A
|
|
986
|
+
// literally named credential file is checked here too: checkRead only
|
|
987
|
+
// catches one reached through a symlink, leaving the literal name to the
|
|
988
|
+
// operand scan below, which never sees `inputs` (`cat < .env`).
|
|
989
|
+
for (const word of segment.inputs) {
|
|
990
|
+
if (isSensitivePath(word.value) || isSensitivePath(toAbsoluteBash(effectiveCwd, word.value, home))) {
|
|
991
|
+
if (!evidence.sensitivePaths.includes(word.value)) evidence.sensitivePaths.push(word.value);
|
|
992
|
+
escalate(`reads ${word.value} on stdin, which is a credential or secret path`, { namedRead: true });
|
|
993
|
+
}
|
|
994
|
+
checkRead(word);
|
|
995
|
+
}
|
|
996
|
+
if (segment.unknownTarget) escalate("redirects to or from a path an expansion computes, which cannot be resolved here");
|
|
997
|
+
// `cat <<< "$TOKEN"` prints the environment's value as surely as `echo $TOKEN`.
|
|
998
|
+
if (segment.expandsIntoInput) escalate("expands a parameter into the command's input, whose value is unknown here");
|
|
999
|
+
|
|
1000
|
+
// A word bash computes at run time. A parameter's value comes from the
|
|
1001
|
+
// environment (`$@` is empty in a `bash -c` line, so `cat $@/etc/passwd`
|
|
1002
|
+
// read as an in-project path); a substitution's output can be any words,
|
|
1003
|
+
// options included. The substituted commands are segments of their own
|
|
1004
|
+
// and judged below like any other.
|
|
1005
|
+
const dynamicKinds = new Set(segment.tokens.map((token) => token.dynamic).filter((kind) => kind !== undefined));
|
|
1006
|
+
if (dynamicKinds.has("variable")) {
|
|
1007
|
+
escalate(
|
|
1008
|
+
segment.tokens.some((token) => token.dynamic === "variable" && SPECIAL_PARAMETER.test(token.value))
|
|
1009
|
+
? "references a shell special parameter ($1, $@, $$, …), whose value is unknown here"
|
|
1010
|
+
: "references environment variables, whose values are unknown here",
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
846
1013
|
|
|
847
|
-
|
|
1014
|
+
// A leading `NAME=value` changes what the command runs or reads: git takes
|
|
1015
|
+
// its repository and configuration from GIT_* variables (the same
|
|
1016
|
+
// capability `git -c` is refused for), loaders and pagers from others, and
|
|
1017
|
+
// a bare `PATH=./bin` segment changes which program every later word runs.
|
|
1018
|
+
// Only locale and display variables are inert (SECURITY-REVIEW-2026-09-23 H2).
|
|
1019
|
+
for (const token of segment.tokens) {
|
|
1020
|
+
const assigned = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(token.value)?.[1];
|
|
1021
|
+
if (!assigned) break;
|
|
1022
|
+
const inert = INERT_ASSIGNMENTS.test(assigned) && isInertValue(assigned, token.value.slice(assigned.length + 1));
|
|
1023
|
+
if (!inert) escalate(`sets ${assigned}, which can change what the command runs or reads`);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const { command: name, args, peeled, pathNamed } = resolvePayload(segment.tokens);
|
|
1027
|
+
if (dynamicKinds.has("substitution") && !PURE_OUTPUT.has(name)) {
|
|
1028
|
+
escalate(`passes a command substitution's output to ${name || "the shell"}, and its words are unknown here`);
|
|
1029
|
+
}
|
|
1030
|
+
// `diff <(git show HEAD:a) a`: bash hands a read-only command a pipe from a command judged on its own.
|
|
1031
|
+
if (dynamicKinds.has("process-input") && !READ_ONLY_COMMANDS.has(name) && name !== "find") {
|
|
1032
|
+
escalate(`passes a <( ) pipe to ${name || "the shell"}, which is not a read-only command`);
|
|
1033
|
+
}
|
|
848
1034
|
if (!name) {
|
|
849
1035
|
// A wrapper with nothing to wrap is a command of its own: a bare `env`
|
|
850
1036
|
// (or `env -i`, `nice`) prints the whole process environment / state.
|
|
@@ -859,12 +1045,15 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
859
1045
|
continue;
|
|
860
1046
|
}
|
|
861
1047
|
evidence.commands.push(name);
|
|
1048
|
+
// `./cat` or `bin/timeout` runs whatever file sits there, not the command
|
|
1049
|
+
// its basename names; Claude Code matches the command word exactly too.
|
|
1050
|
+
for (const word of pathNamed) escalate(`runs ${word}, a program named by its path, so its name says nothing about what it does`);
|
|
862
1051
|
if (peeled.length > 0) {
|
|
863
1052
|
// A transparent wrapper (timeout, env, nice) leaves the payload's own
|
|
864
|
-
// arguments visible, so an in-project payload stays contained
|
|
865
|
-
//
|
|
1053
|
+
// arguments visible, so an in-project payload stays contained unless a
|
|
1054
|
+
// wrapper's spec says otherwise (xargs, flock, script).
|
|
866
1055
|
escalate(`wraps the real command in ${peeled.join(" → ")}, so ${name} is what actually runs`, {
|
|
867
|
-
contained:
|
|
1056
|
+
contained: wrappersContained(peeled),
|
|
868
1057
|
});
|
|
869
1058
|
}
|
|
870
1059
|
|
|
@@ -882,11 +1071,11 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
882
1071
|
if (isSensitivePath(value) || isSensitivePath(absolute)) {
|
|
883
1072
|
// The *original* token, never the resolved/expanded form (N18).
|
|
884
1073
|
if (!evidence.sensitivePaths.includes(value)) evidence.sensitivePaths.push(value);
|
|
885
|
-
escalate(`names ${value}, which is a credential or secret path
|
|
1074
|
+
escalate(`names ${value}, which is a credential or secret path`, { namedRead: true });
|
|
886
1075
|
}
|
|
887
1076
|
if (isExecutionPrimitivePath(absolute)) {
|
|
888
1077
|
if (!evidence.executionPrimitives.includes(value)) evidence.executionPrimitives.push(value);
|
|
889
|
-
escalate(`touches ${value}, whose contents execute later without further approval
|
|
1078
|
+
escalate(`touches ${value}, whose contents execute later without further approval`, { namedRead: true });
|
|
890
1079
|
}
|
|
891
1080
|
}
|
|
892
1081
|
|
|
@@ -894,33 +1083,47 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
894
1083
|
const target = args.find((token) => !token.value.startsWith("-"))?.value;
|
|
895
1084
|
if (target) {
|
|
896
1085
|
effectiveCwd = toAbsoluteBash(effectiveCwd, target, home);
|
|
1086
|
+
cwdByScope.set(segment, effectiveCwd);
|
|
897
1087
|
escalate(`changes directory to ${target}, so later paths in this command resolve elsewhere`);
|
|
898
1088
|
}
|
|
899
1089
|
continue;
|
|
900
1090
|
}
|
|
901
1091
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
escalate("runs git reset --hard, which discards uncommitted changes in the working tree", {
|
|
915
|
-
contained: true,
|
|
916
|
-
});
|
|
917
|
-
continue;
|
|
1092
|
+
// Bash expands a glob before the command parses its options, so a match
|
|
1093
|
+
// that starts with `-` arrives as an option no table judged: with a file
|
|
1094
|
+
// named `--output=victim`, `sort *` writes `victim`
|
|
1095
|
+
// (AUTO-MODE-SECURITY-REVIEW-2026-09-24 H4). A glob this check cannot
|
|
1096
|
+
// enumerate escalates when its expansion could start with `-`.
|
|
1097
|
+
for (const token of args) {
|
|
1098
|
+
if (!token.glob) continue;
|
|
1099
|
+
const matches = expandGlob(effectiveCwd, token.value, home);
|
|
1100
|
+
const injects = matches === undefined ? /^[-*?[]/.test(token.value) : matches.some((match) => match !== token.value && match.startsWith("-"));
|
|
1101
|
+
if (injects) {
|
|
1102
|
+
escalate(`passes the glob ${token.value}, which can expand to a filename starting with -, read as an option`);
|
|
1103
|
+
break;
|
|
918
1104
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (name === "git") {
|
|
1108
|
+
const fileReads: Token[] = [];
|
|
1109
|
+
const reason =
|
|
1110
|
+
gitEscalationReason(
|
|
1111
|
+
args,
|
|
1112
|
+
(dir) => {
|
|
1113
|
+
if (isUnknownTilde(dir)) return true;
|
|
1114
|
+
const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, dir, home));
|
|
1115
|
+
return resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
1116
|
+
},
|
|
1117
|
+
(words) => fileReads.push(...words),
|
|
1118
|
+
) ?? gitCheckoutReason(args, effectiveCwd, home, checkoutReasons);
|
|
923
1119
|
if (reason) escalate(reason);
|
|
1120
|
+
// git resolves a relative operand from its final `-C` directory, not
|
|
1121
|
+
// the shell's: `git -C sub diff ../../x/data.txt a.txt` can climb out
|
|
1122
|
+
// from `sub` where the same word stays inside from the working directory.
|
|
1123
|
+
else {
|
|
1124
|
+
const gitCwd = gitWorkingDirectory(args, effectiveCwd, home);
|
|
1125
|
+
for (const word of fileReads) checkRead(word, gitCwd);
|
|
1126
|
+
}
|
|
924
1127
|
continue;
|
|
925
1128
|
}
|
|
926
1129
|
|
|
@@ -928,53 +1131,124 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
928
1131
|
escalate(`runs ${name}, whose script can read and write files this check cannot see`);
|
|
929
1132
|
}
|
|
930
1133
|
|
|
931
|
-
if (name === "find" && findHasAction(args)) {
|
|
932
|
-
escalate("uses a find action that executes commands or writes files");
|
|
933
|
-
}
|
|
934
|
-
|
|
935
1134
|
const isMutation = MUTATION_COMMANDS.has(name);
|
|
936
1135
|
const isDelete = DELETE_COMMANDS.has(name);
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
// stays uncontained
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
if (
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1136
|
+
// Claude Code's acceptEdits file commands with modelled options are the
|
|
1137
|
+
// contained mutations, their targets checked below; any other mutation
|
|
1138
|
+
// (tar/dd/ln/…) stays uncontained.
|
|
1139
|
+
const fileOperands = acceptEditsOperands(name, args);
|
|
1140
|
+
if (isMutation) escalate(`runs ${name}, which modifies the filesystem`, { contained: fileOperands !== undefined });
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
// A read-only command is proved read-only by its options, not its name:
|
|
1144
|
+
// each option must be in the command's table (read-only-options.ts), so
|
|
1145
|
+
// `rg --pre=<prog>`, `tree -o f` and `uniq in out` escalate
|
|
1146
|
+
// (SECURITY-REVIEW-2026-09-23 H1). Its operands, and the files named by
|
|
1147
|
+
// options such as `grep -f`, are then read-checked.
|
|
1148
|
+
if (name === "find") {
|
|
1149
|
+
const find = checkFind(args);
|
|
1150
|
+
if (!find.ok) escalate(find.reason);
|
|
1151
|
+
else for (const word of find.paths) checkRead(word);
|
|
1152
|
+
} else if (READ_ONLY_COMMANDS.has(name)) {
|
|
1153
|
+
const check = checkOptions(READ_ONLY_SPECS[name], args);
|
|
1154
|
+
if (!check.ok) {
|
|
1155
|
+
escalate(`runs ${name} and ${check.reason}`);
|
|
1156
|
+
} else {
|
|
1157
|
+
const spec = READ_ONLY_SPECS[name];
|
|
1158
|
+
const operandReason = spec.operandReason?.(check.parsed.positionals);
|
|
1159
|
+
if (operandReason) escalate(operandReason);
|
|
1160
|
+
const operands = [...check.parsed.positionals];
|
|
1161
|
+
// A pattern-first command's first operand is its pattern, unless an
|
|
1162
|
+
// option (`-e PAT`, `-f FILE`) supplies it; jq's first is its program.
|
|
1163
|
+
// `rg --files` lists files and takes no pattern, so every operand is a
|
|
1164
|
+
// path (PREGATE-REVIEW-2026-09-23 P3).
|
|
1165
|
+
const patternFirst =
|
|
1166
|
+
PATTERN_FIRST_COMMANDS.has(name) && !check.parsed.seen.has("--files") && ![...check.parsed.seen].some((option) => PATTERN_OPTIONS.has(option));
|
|
1167
|
+
// A glob where the pattern, program or format goes becomes that operand
|
|
1168
|
+
// only in its first match; the rest are operands this check never saw
|
|
1169
|
+
// (`grep *` reads every match but the first, jq's program is a filename).
|
|
1170
|
+
const lead = operands[0];
|
|
1171
|
+
if (lead?.glob && (patternFirst || spec.programFirst || spec.operandReason)) {
|
|
1172
|
+
escalate(`runs ${name} with the glob ${lead.value} as its first operand, which ${name} does not read as a file, so the other matches go unchecked`);
|
|
1173
|
+
}
|
|
1174
|
+
if (patternFirst || spec.programFirst) operands.shift();
|
|
1175
|
+
const reads = NO_FILE_OPERANDS.has(name) ? check.parsed.fileValues : [...operands, ...check.parsed.fileValues];
|
|
1176
|
+
for (const word of reads) checkRead(word);
|
|
1177
|
+
// A command that dereferences the entries of a directory operand
|
|
1178
|
+
// (`diff dir1 dir2`) reads through any symlink one level inside it,
|
|
1179
|
+
// which the operand's own containment check never sees (A1).
|
|
1180
|
+
if (spec.dereferencesDirEntries && !spec.dereferencesDirEntries.some((option) => check.parsed.seen.has(option))) {
|
|
1181
|
+
for (const word of operands) {
|
|
1182
|
+
// A glob could expand to a directory this check cannot enumerate, so
|
|
1183
|
+
// it escalates rather than being skipped (code-review 2026-09-24).
|
|
1184
|
+
if (word.glob) {
|
|
1185
|
+
escalate(`runs ${name} on the glob ${word.value}, which could name a directory that ${FOLLOWS_DIR_ENTRY_SYMLINKS}`);
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, word.value, home));
|
|
1189
|
+
if (resolved !== undefined && isDirectory(resolved)) {
|
|
1190
|
+
escalate(`runs ${name} on the directory ${word.value}, which ${FOLLOWS_DIR_ENTRY_SYMLINKS}`);
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
968
1195
|
}
|
|
1196
|
+
} else if (!isMutation) {
|
|
1197
|
+
escalate(`runs ${name}, which is not on the read-only allowlist`);
|
|
969
1198
|
}
|
|
970
1199
|
|
|
971
1200
|
// The positional destinations of writing commands are the paths that get
|
|
972
1201
|
// written. (Redirections were already handled at the top of the loop.)
|
|
973
1202
|
const writeTokens: string[] = [];
|
|
974
|
-
if (
|
|
1203
|
+
if (fileOperands !== undefined) {
|
|
1204
|
+
// Every operand is a path, bare names included. cp and mv write their
|
|
1205
|
+
// last operand and read the rest, so a source outside the working space
|
|
1206
|
+
// (or a credential) is an outside read and leaves the line uncontained.
|
|
1207
|
+
const copies = name === "cp" || name === "mv";
|
|
1208
|
+
const reads = copies ? fileOperands.slice(0, -1) : [];
|
|
1209
|
+
// The paths the write checks guard, beneath a directory operand as well
|
|
1210
|
+
// as at it: they protect the files, not the directory holding them.
|
|
1211
|
+
const guarded = (path: string) => isGuardedWritePath(path, effectiveCwd, protectedDirs);
|
|
1212
|
+
// Removing or moving away a working root, a `.git`, or a guarded path,
|
|
1213
|
+
// or a directory holding one, is never contained (`removalLoses`).
|
|
1214
|
+
for (const token of name === "mv" ? reads : name === "rm" || name === "rmdir" ? fileOperands : []) {
|
|
1215
|
+
const absolute = toAbsoluteBash(effectiveCwd, token.value, home);
|
|
1216
|
+
const resolved = resolveForContainment(absolute);
|
|
1217
|
+
if (resolved === undefined) continue;
|
|
1218
|
+
if (removalLoses(absolute, resolved, [containmentRoot, ...writableRoots], effectiveCwd, protectedDirs)) {
|
|
1219
|
+
escalate(`${name === "mv" ? "moves" : "removes"} ${token.value}, which is a working root, or is or holds a git repository or a protected, credential or execution-primitive path`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
for (const token of reads) {
|
|
1223
|
+
if (isSensitivePath(toAbsoluteBash(effectiveCwd, token.value, home))) {
|
|
1224
|
+
if (!evidence.sensitivePaths.includes(token.value)) evidence.sensitivePaths.push(token.value);
|
|
1225
|
+
escalate(`copies or moves ${token.value}, a credential or secret path`);
|
|
1226
|
+
}
|
|
1227
|
+
checkRead(token);
|
|
1228
|
+
}
|
|
1229
|
+
const destination = copies ? fileOperands.at(-1) : undefined;
|
|
1230
|
+
if (destination === undefined) {
|
|
1231
|
+
writeTokens.push(...fileOperands.map((token) => token.value));
|
|
1232
|
+
} else if (reads.length === 0) {
|
|
1233
|
+
writeTokens.push(destination.value);
|
|
1234
|
+
} else {
|
|
1235
|
+
// Into an existing directory, each source lands beneath it (`cp
|
|
1236
|
+
// settings.json .claude` writes `.claude/settings.json`), and a
|
|
1237
|
+
// directory source brings its whole tree along.
|
|
1238
|
+
const intoDirectory = destination.value.endsWith("/") || isDirectory(toAbsoluteBash(effectiveCwd, destination.value, home));
|
|
1239
|
+
for (const source of reads) {
|
|
1240
|
+
const target = intoDirectory ? `${destination.value.replace(/\/+$/, "")}/${bashBasename(source.value)}` : destination.value;
|
|
1241
|
+
writeTokens.push(target);
|
|
1242
|
+
const sourceResolved = resolveForContainment(toAbsoluteBash(effectiveCwd, source.value, home));
|
|
1243
|
+
if (sourceResolved !== undefined && touchesGuardedPath(sourceResolved, toAbsoluteBash(effectiveCwd, target, home), guarded)) {
|
|
1244
|
+
escalate(`${name === "cp" ? "copies" : "moves"} ${source.value} to ${target}, which puts files on a protected, credential or execution-primitive path`);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
} else if (isDelete) {
|
|
975
1249
|
// A delete's targets are every non-flag positional, bare names included
|
|
976
|
-
// (`rm notes.txt` has no slash but is still a real target
|
|
977
|
-
//
|
|
1250
|
+
// (`rm notes.txt` has no slash but is still a real target that
|
|
1251
|
+
// must be resolved). A glob target cannot be enumerated, so
|
|
978
1252
|
// it drops out of containment — the command then reaches the classifier.
|
|
979
1253
|
for (const token of args) {
|
|
980
1254
|
if (token.value.startsWith("-")) continue;
|
|
@@ -1013,11 +1287,11 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
|
|
|
1013
1287
|
for (const token of writeTokens) checkWriteTarget(token);
|
|
1014
1288
|
}
|
|
1015
1289
|
|
|
1016
|
-
// The command escalated, but every reason was an
|
|
1017
|
-
//
|
|
1018
|
-
// containment gate may now consult git-recoverability (auto-mode/recoverability).
|
|
1290
|
+
// The command escalated, but every reason was an acceptEdits file command on
|
|
1291
|
+
// the working space: nothing reached outside it, the network, or the unknown.
|
|
1019
1292
|
evidence.containedNonNetwork = evidence.verdict === "escalate" && !uncontained;
|
|
1020
1293
|
evidence.readOnlyOutside = evidence.verdict === "escalate" && !escalatedBeyondReads;
|
|
1294
|
+
evidence.readsOnly = evidence.verdict === "escalate" && !escalatedBeyondNamedReads;
|
|
1021
1295
|
|
|
1022
1296
|
return evidence;
|
|
1023
1297
|
}
|