@lannguyensi/harness 0.63.0 → 1.0.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/CHANGELOG.md +90 -0
- package/README.md +46 -42
- package/dist/cli/add/index.js +17 -21
- package/dist/cli/add/index.js.map +1 -1
- package/dist/cli/adopt/derive.d.ts +2 -2
- package/dist/cli/adopt/derive.js +2 -19
- package/dist/cli/adopt/derive.js.map +1 -1
- package/dist/cli/adopt/index.d.ts +1 -1
- package/dist/cli/adopt/index.js +1 -12
- package/dist/cli/adopt/index.js.map +1 -1
- package/dist/cli/apply/apply.d.ts +20 -35
- package/dist/cli/apply/apply.js +61 -136
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/apply/generate-codex-config.js +10 -58
- package/dist/cli/apply/generate-codex-config.js.map +1 -1
- package/dist/cli/apply/generate-memory-index.js +1 -2
- package/dist/cli/apply/generate-memory-index.js.map +1 -1
- package/dist/cli/apply/generate-opencode-config.d.ts +2 -10
- package/dist/cli/apply/generate-opencode-config.js +74 -113
- package/dist/cli/apply/generate-opencode-config.js.map +1 -1
- package/dist/cli/apply/generate-settings.d.ts +11 -64
- package/dist/cli/apply/generate-settings.js +27 -132
- package/dist/cli/apply/generate-settings.js.map +1 -1
- package/dist/cli/apply/index.d.ts +1 -1
- package/dist/cli/apply/index.js +1 -1
- package/dist/cli/apply/index.js.map +1 -1
- package/dist/cli/apply/install-codex-config.d.ts +153 -1
- package/dist/cli/apply/install-codex-config.js +445 -70
- package/dist/cli/apply/install-codex-config.js.map +1 -1
- package/dist/cli/apply/next-steps.d.ts +7 -0
- package/dist/cli/apply/next-steps.js +13 -1
- package/dist/cli/apply/next-steps.js.map +1 -1
- package/dist/cli/bounded-stdin.d.ts +0 -23
- package/dist/cli/bounded-stdin.js +2 -49
- package/dist/cli/bounded-stdin.js.map +1 -1
- package/dist/cli/describe.d.ts +1 -1
- package/dist/cli/describe.js +0 -3
- package/dist/cli/describe.js.map +1 -1
- package/dist/cli/diff/engine.d.ts +0 -11
- package/dist/cli/diff/engine.js +1 -17
- package/dist/cli/diff/engine.js.map +1 -1
- package/dist/cli/diff/index.js +14 -25
- package/dist/cli/diff/index.js.map +1 -1
- package/dist/cli/doctor/claude-mcp.d.ts +4 -19
- package/dist/cli/doctor/claude-mcp.js +11 -22
- package/dist/cli/doctor/claude-mcp.js.map +1 -1
- package/dist/cli/doctor/codex.d.ts +12 -14
- package/dist/cli/doctor/codex.js +31 -144
- package/dist/cli/doctor/codex.js.map +1 -1
- package/dist/cli/doctor/format.js +23 -338
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/hook-liveness.d.ts +56 -0
- package/dist/cli/doctor/hook-liveness.js +455 -0
- package/dist/cli/doctor/hook-liveness.js.map +1 -0
- package/dist/cli/doctor/index.d.ts +13 -87
- package/dist/cli/doctor/index.js +57 -705
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/opencode.d.ts +2 -1
- package/dist/cli/doctor/opencode.js.map +1 -1
- package/dist/cli/doctor/types.d.ts +21 -378
- package/dist/cli/doctor/types.js.map +1 -1
- package/dist/cli/drain-stdio.d.ts +31 -0
- package/dist/cli/drain-stdio.js +76 -0
- package/dist/cli/drain-stdio.js.map +1 -0
- package/dist/cli/export.d.ts +3 -1
- package/dist/cli/export.js +7 -16
- package/dist/cli/export.js.map +1 -1
- package/dist/cli/gate/codex-disable.d.ts +29 -0
- package/dist/cli/gate/codex-disable.js +195 -0
- package/dist/cli/gate/codex-disable.js.map +1 -0
- package/dist/cli/gate/codex-enable.d.ts +41 -0
- package/dist/cli/gate/codex-enable.js +127 -0
- package/dist/cli/gate/codex-enable.js.map +1 -0
- package/dist/cli/gate/codex-snapshot.d.ts +208 -0
- package/dist/cli/gate/codex-snapshot.js +402 -0
- package/dist/cli/gate/codex-snapshot.js.map +1 -0
- package/dist/cli/gate/disable.d.ts +9 -0
- package/dist/cli/gate/disable.js +263 -28
- package/dist/cli/gate/disable.js.map +1 -1
- package/dist/cli/gate/enable.d.ts +26 -6
- package/dist/cli/gate/enable.js +152 -80
- package/dist/cli/gate/enable.js.map +1 -1
- package/dist/cli/gate/link-chain.d.ts +53 -0
- package/dist/cli/gate/link-chain.js +122 -0
- package/dist/cli/gate/link-chain.js.map +1 -0
- package/dist/cli/gate/snapshot.d.ts +289 -12
- package/dist/cli/gate/snapshot.js +481 -22
- package/dist/cli/gate/snapshot.js.map +1 -1
- package/dist/cli/index.d.ts +0 -9
- package/dist/cli/index.js +15 -2607
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/composer.d.ts +3 -6
- package/dist/cli/init/composer.js +6 -370
- package/dist/cli/init/composer.js.map +1 -1
- package/dist/cli/init/dependencies.d.ts +1 -2
- package/dist/cli/init/dependencies.js +4 -80
- package/dist/cli/init/dependencies.js.map +1 -1
- package/dist/cli/init/detect.js +5 -4
- package/dist/cli/init/detect.js.map +1 -1
- package/dist/cli/init/interactive.d.ts +1 -23
- package/dist/cli/init/interactive.js +33 -247
- package/dist/cli/init/interactive.js.map +1 -1
- package/dist/cli/init/profiles.d.ts +2 -2
- package/dist/cli/init/profiles.js +51 -243
- package/dist/cli/init/profiles.js.map +1 -1
- package/dist/cli/init/templates.d.ts +2 -37
- package/dist/cli/init/templates.js +25 -1237
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/list.d.ts +1 -1
- package/dist/cli/list.js +1 -38
- package/dist/cli/list.js.map +1 -1
- package/dist/cli/loader.d.ts +15 -1
- package/dist/cli/loader.js +56 -39
- package/dist/cli/loader.js.map +1 -1
- package/dist/cli/main.js +7 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/migrate-home/index.js +2 -2
- package/dist/cli/migrate-home/index.js.map +1 -1
- package/dist/cli/pack/add.js +1 -1
- package/dist/cli/pack/add.js.map +1 -1
- package/dist/cli/pack/hook-bootstrap.d.ts +5 -164
- package/dist/cli/pack/hook-bootstrap.js +15 -291
- package/dist/cli/pack/hook-bootstrap.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.d.ts +28 -21
- package/dist/cli/pack/hook-branch-protection.js +365 -340
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/index.d.ts +0 -1
- package/dist/cli/pack/index.js +0 -1
- package/dist/cli/pack/index.js.map +1 -1
- package/dist/cli/pack/list.js +1 -6
- package/dist/cli/pack/list.js.map +1 -1
- package/dist/cli/pack/mutate.d.ts +7 -9
- package/dist/cli/pack/mutate.js +6 -9
- package/dist/cli/pack/mutate.js.map +1 -1
- package/dist/cli/pack/reseed.d.ts +2 -2
- package/dist/cli/pack/reseed.js +16 -22
- package/dist/cli/pack/reseed.js.map +1 -1
- package/dist/cli/register-gate-group.d.ts +5 -0
- package/dist/cli/register-gate-group.js +348 -0
- package/dist/cli/register-gate-group.js.map +1 -0
- package/dist/cli/register-inspect-group.d.ts +6 -0
- package/dist/cli/register-inspect-group.js +189 -0
- package/dist/cli/register-inspect-group.js.map +1 -0
- package/dist/cli/register-operator-lifecycle.d.ts +5 -0
- package/dist/cli/register-operator-lifecycle.js +20 -0
- package/dist/cli/register-operator-lifecycle.js.map +1 -0
- package/dist/cli/register-pack-group.d.ts +5 -0
- package/dist/cli/register-pack-group.js +134 -0
- package/dist/cli/register-pack-group.js.map +1 -0
- package/dist/cli/register-setup-group.d.ts +5 -0
- package/dist/cli/register-setup-group.js +495 -0
- package/dist/cli/register-setup-group.js.map +1 -0
- package/dist/cli/remove/index.d.ts +0 -14
- package/dist/cli/remove/index.js +2 -115
- package/dist/cli/remove/index.js.map +1 -1
- package/dist/cli/remove/mutate.d.ts +0 -2
- package/dist/cli/remove/mutate.js +1 -19
- package/dist/cli/remove/mutate.js.map +1 -1
- package/dist/cli/remove/register.d.ts +5 -0
- package/dist/cli/remove/register.js +25 -0
- package/dist/cli/remove/register.js.map +1 -0
- package/dist/cli/shell-quote.d.ts +7 -0
- package/dist/cli/shell-quote.js +17 -0
- package/dist/cli/shell-quote.js.map +1 -0
- package/dist/cli/uninstall/index.d.ts +4 -2
- package/dist/cli/uninstall/index.js +15 -7
- package/dist/cli/uninstall/index.js.map +1 -1
- package/dist/cli/uninstall/register.d.ts +5 -0
- package/dist/cli/uninstall/register.js +171 -0
- package/dist/cli/uninstall/register.js.map +1 -0
- package/dist/cli/uninstall/snapshot.d.ts +15 -0
- package/dist/cli/uninstall/snapshot.js +38 -0
- package/dist/cli/uninstall/snapshot.js.map +1 -1
- package/dist/cli/validate/checks.d.ts +0 -77
- package/dist/cli/validate/checks.js +3 -1049
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/cli/validate/index.js +19 -15
- package/dist/cli/validate/index.js.map +1 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -3
- package/dist/index.js.map +1 -1
- package/dist/io/claude-mcp.d.ts +1 -1
- package/dist/io/claude-mcp.js +27 -14
- package/dist/io/claude-mcp.js.map +1 -1
- package/dist/io/harness-lock.js +2 -2
- package/dist/io/harness-lock.js.map +1 -1
- package/dist/io/lock.d.ts +0 -26
- package/dist/io/lock.js +10 -27
- package/dist/io/lock.js.map +1 -1
- package/dist/io/merge-settings.js +6 -4
- package/dist/io/merge-settings.js.map +1 -1
- package/dist/io/project-name.d.ts +22 -39
- package/dist/io/project-name.js +25 -42
- package/dist/io/project-name.js.map +1 -1
- package/dist/io/read-regular-file.d.ts +103 -40
- package/dist/io/read-regular-file.js +196 -35
- package/dist/io/read-regular-file.js.map +1 -1
- package/dist/io/restart-hints.js +3 -6
- package/dist/io/restart-hints.js.map +1 -1
- package/dist/io/version-compare.d.ts +1 -3
- package/dist/io/version-compare.js +1 -3
- package/dist/io/version-compare.js.map +1 -1
- package/dist/io/write-regular-file.d.ts +30 -0
- package/dist/io/write-regular-file.js +85 -0
- package/dist/io/write-regular-file.js.map +1 -0
- package/dist/overrides/index.d.ts +1 -0
- package/dist/overrides/index.js +1 -0
- package/dist/overrides/index.js.map +1 -1
- package/dist/overrides/merge-layers.d.ts +43 -0
- package/dist/overrides/merge-layers.js +76 -0
- package/dist/overrides/merge-layers.js.map +1 -0
- package/dist/overrides/merge.d.ts +16 -0
- package/dist/overrides/merge.js +21 -8
- package/dist/overrides/merge.js.map +1 -1
- package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +4 -69
- package/dist/policy-packs/builtin/branch-protection-runtime.js +12 -84
- package/dist/policy-packs/builtin/branch-protection-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/branch-protection.d.ts +7 -30
- package/dist/policy-packs/builtin/branch-protection.js +56 -89
- package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
- package/dist/policy-packs/config-check.d.ts +7 -5
- package/dist/policy-packs/config-check.js +2 -2
- package/dist/policy-packs/config-check.js.map +1 -1
- package/dist/policy-packs/expand.d.ts +1 -3
- package/dist/policy-packs/expand.js +5 -42
- package/dist/policy-packs/expand.js.map +1 -1
- package/dist/policy-packs/index.d.ts +3 -3
- package/dist/policy-packs/index.js +2 -2
- package/dist/policy-packs/index.js.map +1 -1
- package/dist/policy-packs/registry.d.ts +9 -38
- package/dist/policy-packs/registry.js +5 -65
- package/dist/policy-packs/registry.js.map +1 -1
- package/dist/policy-packs/runtime.d.ts +0 -4
- package/dist/policy-packs/runtime.js +9 -22
- package/dist/policy-packs/runtime.js.map +1 -1
- package/dist/policy-packs/types.d.ts +0 -19
- package/dist/policy-packs/ux-compare.d.ts +1 -11
- package/dist/policy-packs/ux-compare.js +6 -42
- package/dist/policy-packs/ux-compare.js.map +1 -1
- package/dist/policy-packs/ux-drift-check.d.ts +5 -5
- package/dist/policy-packs/ux-drift-check.js +17 -22
- package/dist/policy-packs/ux-drift-check.js.map +1 -1
- package/dist/policy-packs/version-check.d.ts +5 -26
- package/dist/policy-packs/version-check.js +19 -74
- package/dist/policy-packs/version-check.js.map +1 -1
- package/dist/probes/memory.d.ts +1 -1
- package/dist/probes/memory.js +1 -1
- package/dist/probes/memory.js.map +1 -1
- package/dist/runtime/agent-facing.d.ts +9 -0
- package/dist/runtime/agent-facing.js +28 -14
- package/dist/runtime/agent-facing.js.map +1 -1
- package/dist/runtime/bounded-text.d.ts +15 -0
- package/dist/runtime/bounded-text.js +27 -0
- package/dist/runtime/bounded-text.js.map +1 -0
- package/dist/runtime/git-branch.d.ts +125 -0
- package/dist/runtime/git-branch.js +381 -0
- package/dist/runtime/git-branch.js.map +1 -0
- package/dist/runtime/hermetic-spawn-guard.d.ts +2 -2
- package/dist/runtime/hermetic-spawn-guard.js +24 -34
- package/dist/runtime/hermetic-spawn-guard.js.map +1 -1
- package/dist/runtime/home-dir.js +3 -2
- package/dist/runtime/home-dir.js.map +1 -1
- package/dist/runtime/tool-name-aliases.d.ts +0 -6
- package/dist/runtime/tool-name-aliases.js +5 -18
- package/dist/runtime/tool-name-aliases.js.map +1 -1
- package/dist/schema/grounding.d.ts +7 -54
- package/dist/schema/grounding.js +10 -12
- package/dist/schema/grounding.js.map +1 -1
- package/dist/schema/hooks.d.ts +39 -136
- package/dist/schema/hooks.js +3 -6
- package/dist/schema/hooks.js.map +1 -1
- package/dist/schema/index.d.ts +114 -3828
- package/dist/schema/index.js +41 -80
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/memory.d.ts +54 -131
- package/dist/schema/memory.js +2 -2
- package/dist/schema/memory.js.map +1 -1
- package/dist/schema/policy-packs.d.ts +3 -45
- package/dist/schema/policy-packs.js +7 -13
- package/dist/schema/policy-packs.js.map +1 -1
- package/dist/schema/policy-ux.d.ts +7 -0
- package/dist/schema/policy-ux.js +18 -0
- package/dist/schema/policy-ux.js.map +1 -0
- package/dist/schema/removed-keys.d.ts +185 -0
- package/dist/schema/removed-keys.js +531 -0
- package/dist/schema/removed-keys.js.map +1 -0
- package/dist/schema/tools.d.ts +30 -268
- package/dist/schema/tools.js +4 -4
- package/dist/schema/tools.js.map +1 -1
- package/package.json +5 -16
- package/dist/cli/approve/branch-protection.d.ts +0 -69
- package/dist/cli/approve/branch-protection.js +0 -144
- package/dist/cli/approve/branch-protection.js.map +0 -1
- package/dist/cli/approve/risk.d.ts +0 -112
- package/dist/cli/approve/risk.js +0 -197
- package/dist/cli/approve/risk.js.map +0 -1
- package/dist/cli/approve/stdin-report.d.ts +0 -57
- package/dist/cli/approve/stdin-report.js +0 -145
- package/dist/cli/approve/stdin-report.js.map +0 -1
- package/dist/cli/approve/understanding.d.ts +0 -410
- package/dist/cli/approve/understanding.js +0 -996
- package/dist/cli/approve/understanding.js.map +0 -1
- package/dist/cli/audit.d.ts +0 -80
- package/dist/cli/audit.js +0 -252
- package/dist/cli/audit.js.map +0 -1
- package/dist/cli/delegate/index.d.ts +0 -76
- package/dist/cli/delegate/index.js +0 -443
- package/dist/cli/delegate/index.js.map +0 -1
- package/dist/cli/doctor/auto-approve-mode.d.ts +0 -37
- package/dist/cli/doctor/auto-approve-mode.js +0 -80
- package/dist/cli/doctor/auto-approve-mode.js.map +0 -1
- package/dist/cli/doctor/bypass-without-auto-approve.d.ts +0 -38
- package/dist/cli/doctor/bypass-without-auto-approve.js +0 -95
- package/dist/cli/doctor/bypass-without-auto-approve.js.map +0 -1
- package/dist/cli/doctor/codex-config-drift.d.ts +0 -28
- package/dist/cli/doctor/codex-config-drift.js +0 -240
- package/dist/cli/doctor/codex-config-drift.js.map +0 -1
- package/dist/cli/doctor/rogue-ledger.d.ts +0 -53
- package/dist/cli/doctor/rogue-ledger.js +0 -153
- package/dist/cli/doctor/rogue-ledger.js.map +0 -1
- package/dist/cli/doctor/session-start-preflight-setup-version.d.ts +0 -83
- package/dist/cli/doctor/session-start-preflight-setup-version.js +0 -114
- package/dist/cli/doctor/session-start-preflight-setup-version.js.map +0 -1
- package/dist/cli/doctor/settings-drift.d.ts +0 -25
- package/dist/cli/doctor/settings-drift.js +0 -213
- package/dist/cli/doctor/settings-drift.js.map +0 -1
- package/dist/cli/doctor/toolchain-parity.d.ts +0 -63
- package/dist/cli/doctor/toolchain-parity.js +0 -154
- package/dist/cli/doctor/toolchain-parity.js.map +0 -1
- package/dist/cli/doctor/ug-auto-approvals.d.ts +0 -40
- package/dist/cli/doctor/ug-auto-approvals.js +0 -91
- package/dist/cli/doctor/ug-auto-approvals.js.map +0 -1
- package/dist/cli/doctor/ug-delegations.d.ts +0 -26
- package/dist/cli/doctor/ug-delegations.js +0 -121
- package/dist/cli/doctor/ug-delegations.js.map +0 -1
- package/dist/cli/doctor/ug-inflight.d.ts +0 -35
- package/dist/cli/doctor/ug-inflight.js +0 -57
- package/dist/cli/doctor/ug-inflight.js.map +0 -1
- package/dist/cli/doctor/understanding-mode-env.d.ts +0 -51
- package/dist/cli/doctor/understanding-mode-env.js +0 -98
- package/dist/cli/doctor/understanding-mode-env.js.map +0 -1
- package/dist/cli/dry-run.d.ts +0 -46
- package/dist/cli/dry-run.js +0 -250
- package/dist/cli/dry-run.js.map +0 -1
- package/dist/cli/event-input.d.ts +0 -28
- package/dist/cli/event-input.js +0 -73
- package/dist/cli/event-input.js.map +0 -1
- package/dist/cli/explain-action.d.ts +0 -20
- package/dist/cli/explain-action.js +0 -27
- package/dist/cli/explain-action.js.map +0 -1
- package/dist/cli/explain-policy.d.ts +0 -135
- package/dist/cli/explain-policy.js +0 -266
- package/dist/cli/explain-policy.js.map +0 -1
- package/dist/cli/explain.d.ts +0 -31
- package/dist/cli/explain.js +0 -159
- package/dist/cli/explain.js.map +0 -1
- package/dist/cli/gc/index.d.ts +0 -63
- package/dist/cli/gc/index.js +0 -566
- package/dist/cli/gc/index.js.map +0 -1
- package/dist/cli/pack/approve-escape.d.ts +0 -19
- package/dist/cli/pack/approve-escape.js +0 -190
- package/dist/cli/pack/approve-escape.js.map +0 -1
- package/dist/cli/pack/auto-approve-path.d.ts +0 -146
- package/dist/cli/pack/auto-approve-path.js +0 -462
- package/dist/cli/pack/auto-approve-path.js.map +0 -1
- package/dist/cli/pack/hook-codex-post-tool-use.d.ts +0 -25
- package/dist/cli/pack/hook-codex-post-tool-use.js +0 -160
- package/dist/cli/pack/hook-codex-post-tool-use.js.map +0 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +0 -48
- package/dist/cli/pack/hook-codex-pre-tool-use.js +0 -474
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +0 -1
- package/dist/cli/pack/hook-codex-stop.d.ts +0 -35
- package/dist/cli/pack/hook-codex-stop.js +0 -329
- package/dist/cli/pack/hook-codex-stop.js.map +0 -1
- package/dist/cli/pack/hook-codex-user-prompt-submit.d.ts +0 -24
- package/dist/cli/pack/hook-codex-user-prompt-submit.js +0 -93
- package/dist/cli/pack/hook-codex-user-prompt-submit.js.map +0 -1
- package/dist/cli/pack/hook-post-merge-gate-record.d.ts +0 -37
- package/dist/cli/pack/hook-post-merge-gate-record.js +0 -190
- package/dist/cli/pack/hook-post-merge-gate-record.js.map +0 -1
- package/dist/cli/pack/hook-post-merge-gate.d.ts +0 -34
- package/dist/cli/pack/hook-post-merge-gate.js +0 -277
- package/dist/cli/pack/hook-post-merge-gate.js.map +0 -1
- package/dist/cli/pack/hook-post-tool-use.d.ts +0 -45
- package/dist/cli/pack/hook-post-tool-use.js +0 -137
- package/dist/cli/pack/hook-post-tool-use.js.map +0 -1
- package/dist/cli/pack/hook-pre-tool-use.d.ts +0 -92
- package/dist/cli/pack/hook-pre-tool-use.js +0 -1328
- package/dist/cli/pack/hook-pre-tool-use.js.map +0 -1
- package/dist/cli/pack/hook-runtime-reality.d.ts +0 -63
- package/dist/cli/pack/hook-runtime-reality.js +0 -210
- package/dist/cli/pack/hook-runtime-reality.js.map +0 -1
- package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +0 -32
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js +0 -337
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +0 -1
- package/dist/cli/pack/hook-solution-acceptance.d.ts +0 -157
- package/dist/cli/pack/hook-solution-acceptance.js +0 -530
- package/dist/cli/pack/hook-solution-acceptance.js.map +0 -1
- package/dist/cli/pack/hook-stay-in-scope.d.ts +0 -37
- package/dist/cli/pack/hook-stay-in-scope.js +0 -128
- package/dist/cli/pack/hook-stay-in-scope.js.map +0 -1
- package/dist/cli/pack/hook-subagent-start.d.ts +0 -23
- package/dist/cli/pack/hook-subagent-start.js +0 -88
- package/dist/cli/pack/hook-subagent-start.js.map +0 -1
- package/dist/cli/pack/hook-subagent-stop.d.ts +0 -19
- package/dist/cli/pack/hook-subagent-stop.js +0 -41
- package/dist/cli/pack/hook-subagent-stop.js.map +0 -1
- package/dist/cli/pack/hook-track-active-claim.d.ts +0 -27
- package/dist/cli/pack/hook-track-active-claim.js +0 -275
- package/dist/cli/pack/hook-track-active-claim.js.map +0 -1
- package/dist/cli/pack/transcript-report-scan.d.ts +0 -71
- package/dist/cli/pack/transcript-report-scan.js +0 -359
- package/dist/cli/pack/transcript-report-scan.js.map +0 -1
- package/dist/cli/pack/understanding-report-schema-hint.d.ts +0 -26
- package/dist/cli/pack/understanding-report-schema-hint.js +0 -120
- package/dist/cli/pack/understanding-report-schema-hint.js.map +0 -1
- package/dist/cli/pack/upgrade.d.ts +0 -32
- package/dist/cli/pack/upgrade.js +0 -273
- package/dist/cli/pack/upgrade.js.map +0 -1
- package/dist/cli/pause/index.d.ts +0 -113
- package/dist/cli/pause/index.js +0 -251
- package/dist/cli/pause/index.js.map +0 -1
- package/dist/cli/pause-check.d.ts +0 -22
- package/dist/cli/pause-check.js +0 -54
- package/dist/cli/pause-check.js.map +0 -1
- package/dist/cli/policy/intercept.d.ts +0 -233
- package/dist/cli/policy/intercept.js +0 -1020
- package/dist/cli/policy/intercept.js.map +0 -1
- package/dist/cli/policy/risk-envelope-enrichment.d.ts +0 -40
- package/dist/cli/policy/risk-envelope-enrichment.js +0 -108
- package/dist/cli/policy/risk-envelope-enrichment.js.map +0 -1
- package/dist/cli/record/index.d.ts +0 -81
- package/dist/cli/record/index.js +0 -348
- package/dist/cli/record/index.js.map +0 -1
- package/dist/cli/resolve-env.d.ts +0 -32
- package/dist/cli/resolve-env.js +0 -47
- package/dist/cli/resolve-env.js.map +0 -1
- package/dist/cli/session-export/index.d.ts +0 -46
- package/dist/cli/session-export/index.js +0 -169
- package/dist/cli/session-export/index.js.map +0 -1
- package/dist/cli/session-export/redact.d.ts +0 -22
- package/dist/cli/session-export/redact.js +0 -47
- package/dist/cli/session-export/redact.js.map +0 -1
- package/dist/cli/session-export/transcript.d.ts +0 -24
- package/dist/cli/session-export/transcript.js +0 -164
- package/dist/cli/session-export/transcript.js.map +0 -1
- package/dist/cli/session-start/branch-check.d.ts +0 -18
- package/dist/cli/session-start/branch-check.js +0 -140
- package/dist/cli/session-start/branch-check.js.map +0 -1
- package/dist/cli/session-start/index.d.ts +0 -121
- package/dist/cli/session-start/index.js +0 -591
- package/dist/cli/session-start/index.js.map +0 -1
- package/dist/cli/session-start/shared-options.d.ts +0 -103
- package/dist/cli/session-start/shared-options.js +0 -107
- package/dist/cli/session-start/shared-options.js.map +0 -1
- package/dist/cli/session-start/stale-base-check.d.ts +0 -46
- package/dist/cli/session-start/stale-base-check.js +0 -352
- package/dist/cli/session-start/stale-base-check.js.map +0 -1
- package/dist/cli/session-start/toolchain-parity.d.ts +0 -267
- package/dist/cli/session-start/toolchain-parity.js +0 -797
- package/dist/cli/session-start/toolchain-parity.js.map +0 -1
- package/dist/cli/smoke/assertions.d.ts +0 -46
- package/dist/cli/smoke/assertions.js +0 -170
- package/dist/cli/smoke/assertions.js.map +0 -1
- package/dist/cli/smoke/index.d.ts +0 -76
- package/dist/cli/smoke/index.js +0 -245
- package/dist/cli/smoke/index.js.map +0 -1
- package/dist/cli/smoke/runner.d.ts +0 -55
- package/dist/cli/smoke/runner.js +0 -134
- package/dist/cli/smoke/runner.js.map +0 -1
- package/dist/cli/smoke/stream-parser.d.ts +0 -65
- package/dist/cli/smoke/stream-parser.js +0 -115
- package/dist/cli/smoke/stream-parser.js.map +0 -1
- package/dist/cli/test-risk.d.ts +0 -26
- package/dist/cli/test-risk.js +0 -34
- package/dist/cli/test-risk.js.map +0 -1
- package/dist/io/display-path.d.ts +0 -11
- package/dist/io/display-path.js +0 -29
- package/dist/io/display-path.js.map +0 -1
- package/dist/io/duration.d.ts +0 -5
- package/dist/io/duration.js +0 -50
- package/dist/io/duration.js.map +0 -1
- package/dist/io/extract.d.ts +0 -79
- package/dist/io/extract.js +0 -226
- package/dist/io/extract.js.map +0 -1
- package/dist/io/ledger-record.d.ts +0 -83
- package/dist/io/ledger-record.js +0 -301
- package/dist/io/ledger-record.js.map +0 -1
- package/dist/io/read-json-dir-entries.d.ts +0 -36
- package/dist/io/read-json-dir-entries.js +0 -48
- package/dist/io/read-json-dir-entries.js.map +0 -1
- package/dist/io/safe-json-parse.d.ts +0 -5
- package/dist/io/safe-json-parse.js +0 -21
- package/dist/io/safe-json-parse.js.map +0 -1
- package/dist/policies/index.d.ts +0 -5
- package/dist/policies/index.js +0 -6
- package/dist/policies/index.js.map +0 -1
- package/dist/policies/ledger-client.d.ts +0 -113
- package/dist/policies/ledger-client.js +0 -500
- package/dist/policies/ledger-client.js.map +0 -1
- package/dist/policies/producers.d.ts +0 -12
- package/dist/policies/producers.js +0 -61
- package/dist/policies/producers.js.map +0 -1
- package/dist/policies/requires.d.ts +0 -75
- package/dist/policies/requires.js +0 -240
- package/dist/policies/requires.js.map +0 -1
- package/dist/policies/timestamp.d.ts +0 -14
- package/dist/policies/timestamp.js +0 -36
- package/dist/policies/timestamp.js.map +0 -1
- package/dist/policy-packs/builtin/permission-profiles.d.ts +0 -11
- package/dist/policy-packs/builtin/permission-profiles.js +0 -74
- package/dist/policy-packs/builtin/permission-profiles.js.map +0 -1
- package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +0 -286
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js +0 -397
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +0 -1
- package/dist/policy-packs/builtin/post-merge-gate.d.ts +0 -56
- package/dist/policy-packs/builtin/post-merge-gate.js +0 -347
- package/dist/policy-packs/builtin/post-merge-gate.js.map +0 -1
- package/dist/policy-packs/builtin/solution-acceptance-reconnect.d.ts +0 -50
- package/dist/policy-packs/builtin/solution-acceptance-reconnect.js +0 -151
- package/dist/policy-packs/builtin/solution-acceptance-reconnect.js.map +0 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +0 -323
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +0 -672
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +0 -1
- package/dist/policy-packs/builtin/solution-acceptance.d.ts +0 -45
- package/dist/policy-packs/builtin/solution-acceptance.js +0 -226
- package/dist/policy-packs/builtin/solution-acceptance.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.d.ts +0 -25
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +0 -94
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/auto-approve-default.d.ts +0 -35
- package/dist/policy-packs/builtin/understanding-before-execution/auto-approve-default.js +0 -86
- package/dist/policy-packs/builtin/understanding-before-execution/auto-approve-default.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.d.ts +0 -210
- package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.js +0 -361
- package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.d.ts +0 -304
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js +0 -734
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.d.ts +0 -36
- package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js +0 -73
- package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +0 -19
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +0 -83
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.d.ts +0 -135
- package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js +0 -618
- package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.d.ts +0 -25
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.js +0 -56
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +0 -20
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +0 -93
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +0 -126
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js +0 -245
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.d.ts +0 -29
- package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.js +0 -82
- package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.d.ts +0 -71
- package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.js +0 -175
- package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +0 -620
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +0 -1047
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +0 -128
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +0 -213
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.d.ts +0 -129
- package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.js +0 -166
- package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +0 -164
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +0 -275
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +0 -10
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +0 -1
- package/dist/policy-packs/builtin/understanding-before-execution.d.ts +0 -526
- package/dist/policy-packs/builtin/understanding-before-execution.js +0 -1074
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +0 -1
- package/dist/policy-packs/permission-translator.d.ts +0 -9
- package/dist/policy-packs/permission-translator.js +0 -76
- package/dist/policy-packs/permission-translator.js.map +0 -1
- package/dist/runtime/action-envelope.d.ts +0 -64
- package/dist/runtime/action-envelope.js +0 -46
- package/dist/runtime/action-envelope.js.map +0 -1
- package/dist/runtime/approval-signing.d.ts +0 -111
- package/dist/runtime/approval-signing.js +0 -384
- package/dist/runtime/approval-signing.js.map +0 -1
- package/dist/runtime/bash-match-facts.d.ts +0 -60
- package/dist/runtime/bash-match-facts.js +0 -196
- package/dist/runtime/bash-match-facts.js.map +0 -1
- package/dist/runtime/bash-match-registry.d.ts +0 -112
- package/dist/runtime/bash-match-registry.js +0 -406
- package/dist/runtime/bash-match-registry.js.map +0 -1
- package/dist/runtime/bash-prefix-parse.d.ts +0 -31
- package/dist/runtime/bash-prefix-parse.js +0 -576
- package/dist/runtime/bash-prefix-parse.js.map +0 -1
- package/dist/runtime/command-normalize.d.ts +0 -658
- package/dist/runtime/command-normalize.js +0 -2410
- package/dist/runtime/command-normalize.js.map +0 -1
- package/dist/runtime/deletion-target-resolve.d.ts +0 -28
- package/dist/runtime/deletion-target-resolve.js +0 -1078
- package/dist/runtime/deletion-target-resolve.js.map +0 -1
- package/dist/runtime/destructive-shell-floor.d.ts +0 -19
- package/dist/runtime/destructive-shell-floor.js +0 -611
- package/dist/runtime/destructive-shell-floor.js.map +0 -1
- package/dist/runtime/environment-resolver.d.ts +0 -36
- package/dist/runtime/environment-resolver.js +0 -138
- package/dist/runtime/environment-resolver.js.map +0 -1
- package/dist/runtime/git-context.d.ts +0 -201
- package/dist/runtime/git-context.js +0 -422
- package/dist/runtime/git-context.js.map +0 -1
- package/dist/runtime/index.d.ts +0 -12
- package/dist/runtime/index.js +0 -13
- package/dist/runtime/index.js.map +0 -1
- package/dist/runtime/intercept.d.ts +0 -494
- package/dist/runtime/intercept.js +0 -1313
- package/dist/runtime/intercept.js.map +0 -1
- package/dist/runtime/kube-context.d.ts +0 -16
- package/dist/runtime/kube-context.js +0 -63
- package/dist/runtime/kube-context.js.map +0 -1
- package/dist/runtime/kubectl-target-parse.d.ts +0 -31
- package/dist/runtime/kubectl-target-parse.js +0 -249
- package/dist/runtime/kubectl-target-parse.js.map +0 -1
- package/dist/runtime/ledger-add.d.ts +0 -16
- package/dist/runtime/ledger-add.js +0 -150
- package/dist/runtime/ledger-add.js.map +0 -1
- package/dist/runtime/ledger-writer.d.ts +0 -27
- package/dist/runtime/ledger-writer.js +0 -46
- package/dist/runtime/ledger-writer.js.map +0 -1
- package/dist/runtime/pause-sentinel.d.ts +0 -60
- package/dist/runtime/pause-sentinel.js +0 -156
- package/dist/runtime/pause-sentinel.js.map +0 -1
- package/dist/runtime/pending-approval.d.ts +0 -23
- package/dist/runtime/pending-approval.js +0 -67
- package/dist/runtime/pending-approval.js.map +0 -1
- package/dist/runtime/read-only-bash.d.ts +0 -141
- package/dist/runtime/read-only-bash.js +0 -2204
- package/dist/runtime/read-only-bash.js.map +0 -1
- package/dist/runtime/recovery-git-commit.d.ts +0 -22
- package/dist/runtime/recovery-git-commit.js +0 -284
- package/dist/runtime/recovery-git-commit.js.map +0 -1
- package/dist/runtime/reject-malformed-session-id.d.ts +0 -8
- package/dist/runtime/reject-malformed-session-id.js +0 -16
- package/dist/runtime/reject-malformed-session-id.js.map +0 -1
- package/dist/runtime/risk-classifier.d.ts +0 -38
- package/dist/runtime/risk-classifier.js +0 -311
- package/dist/runtime/risk-classifier.js.map +0 -1
- package/dist/runtime/session-id.d.ts +0 -113
- package/dist/runtime/session-id.js +0 -193
- package/dist/runtime/session-id.js.map +0 -1
- package/dist/runtime/shell-word.d.ts +0 -62
- package/dist/runtime/shell-word.js +0 -410
- package/dist/runtime/shell-word.js.map +0 -1
- package/dist/runtime/task-providers/agent-tasks.d.ts +0 -103
- package/dist/runtime/task-providers/agent-tasks.js +0 -245
- package/dist/runtime/task-providers/agent-tasks.js.map +0 -1
- package/dist/runtime/when-eval.d.ts +0 -62
- package/dist/runtime/when-eval.js +0 -212
- package/dist/runtime/when-eval.js.map +0 -1
- package/dist/runtime/workflow-policies.d.ts +0 -207
- package/dist/runtime/workflow-policies.js +0 -596
- package/dist/runtime/workflow-policies.js.map +0 -1
- package/dist/schema/audit.d.ts +0 -71
- package/dist/schema/audit.js +0 -32
- package/dist/schema/audit.js.map +0 -1
- package/dist/schema/doctor.d.ts +0 -25
- package/dist/schema/doctor.js +0 -23
- package/dist/schema/doctor.js.map +0 -1
- package/dist/schema/environments.d.ts +0 -215
- package/dist/schema/environments.js +0 -101
- package/dist/schema/environments.js.map +0 -1
- package/dist/schema/extract.d.ts +0 -9
- package/dist/schema/extract.js +0 -86
- package/dist/schema/extract.js.map +0 -1
- package/dist/schema/permission-profiles.d.ts +0 -2292
- package/dist/schema/permission-profiles.js +0 -60
- package/dist/schema/permission-profiles.js.map +0 -1
- package/dist/schema/policies.d.ts +0 -974
- package/dist/schema/policies.js +0 -298
- package/dist/schema/policies.js.map +0 -1
- package/dist/schema/requires.d.ts +0 -131
- package/dist/schema/requires.js +0 -88
- package/dist/schema/requires.js.map +0 -1
- package/dist/schema/risk.d.ts +0 -144
- package/dist/schema/risk.js +0 -194
- package/dist/schema/risk.js.map +0 -1
- package/dist/schema/session-start-preflight.d.ts +0 -10
- package/dist/schema/session-start-preflight.js +0 -106
- package/dist/schema/session-start-preflight.js.map +0 -1
- package/dist/schema/stale-base-check.d.ts +0 -18
- package/dist/schema/stale-base-check.js +0 -37
- package/dist/schema/stale-base-check.js.map +0 -1
- package/dist/schema/toolchain-parity.d.ts +0 -21
- package/dist/schema/toolchain-parity.js +0 -48
- package/dist/schema/toolchain-parity.js.map +0 -1
- package/dist/schema/workflows.d.ts +0 -519
- package/dist/schema/workflows.js +0 -81
- package/dist/schema/workflows.js.map +0 -1
- package/scripts/runtime-reality-docker-probe.mjs +0 -71
|
@@ -1,2410 +0,0 @@
|
|
|
1
|
-
// Trigger-matching normaliser for Bash command strings.
|
|
2
|
-
//
|
|
3
|
-
// STATUS (read this before wiring anything new to `targetDir`/
|
|
4
|
-
// `targetBase`): this module has two independent outputs with different
|
|
5
|
-
// consumption status. `normalized` — the command with wrapper prefixes
|
|
6
|
-
// peeled and a git invocation's own global options collapsed — is LIVE:
|
|
7
|
-
// `policyMatchesEvent` (`src/runtime/intercept.ts`) tests every
|
|
8
|
-
// `bash_match` regex against the raw command first and, only on a raw
|
|
9
|
-
// miss, against this normalised form too (raw-OR-normalised, never raw-
|
|
10
|
-
// replaced-by-normalised), closing a class of silent trigger bypasses
|
|
11
|
-
// (see below) — and, only on a further miss of THIS form, against a
|
|
12
|
-
// SECOND, independent, ampersand-aware normalisation pass (`AMP_BOUNDARY_RE`
|
|
13
|
-
// / `normalizeCommandAmpAware` below, task aabbad63) that this header
|
|
14
|
-
// otherwise never mentions; see that function's own comment for its scope
|
|
15
|
-
// and why it carries no `targetDir`/`targetBase` of its own (fix round 1,
|
|
16
|
-
// finding F5 — added so a reader of this header, the module's index,
|
|
17
|
-
// learns a second pass exists at all instead of finding it only by
|
|
18
|
-
// reading past this point). `targetDir` / `targetBase` — the effective target
|
|
19
|
-
// directory of the command's own git invocation(s), when one can be
|
|
20
|
-
// named unambiguously — are NOT WIRED TO ANY GATE. An earlier version of
|
|
21
|
-
// this run used them to resolve the `${REPO}`/`${BRANCH}` policy
|
|
22
|
-
// builtins (`src/cli/policy/intercept.ts`) to a command's named target
|
|
23
|
-
// instead of the hook's own cwd; that wiring produced three consecutive
|
|
24
|
-
// review-round security regressions (Risk Gate risk declassification, a
|
|
25
|
-
// push-gate multi-invocation fail-open, and a non-git gated verb leaking
|
|
26
|
-
// a decoy repo's target, first via `&&`, still open via `|`/`||`), each
|
|
27
|
-
// only partially closeable because a single per-event `targetDir` cannot
|
|
28
|
-
// express "this invocation targets repo B, but the gated verb after it
|
|
29
|
-
// runs in the caller's cwd." The wiring was removed before shipping (see
|
|
30
|
-
// `src/cli/policy/intercept.ts` and `CHANGELOG.md`); the extraction and
|
|
31
|
-
// its own ambiguity rules stay here, fully tested, as the foundation for
|
|
32
|
-
// a redesign that attributes a target to the SEGMENT satisfying a
|
|
33
|
-
// policy's own trigger instead of to the whole event — follow-up task
|
|
34
|
-
// `98ad072f`. Nothing below describes gate wiring; it describes what
|
|
35
|
-
// THIS MODULE computes, live or not.
|
|
36
|
-
//
|
|
37
|
-
// UPDATE (task `98ad072f`, run `2026-08-02-per-repo-gate-scoping-
|
|
38
|
-
// redesign`, T-002): the per-segment foundation named above now EXISTS as
|
|
39
|
-
// a real export — `segmentViewOf` / `CommandSegment`, defined below
|
|
40
|
-
// `normalizeCommand` — instead of being only a design intention. It
|
|
41
|
-
// exposes, per boundary-delimited segment, the canonicalised text, the
|
|
42
|
-
// segment's OWN explicit target, and an EFFECTIVE target composed with the
|
|
43
|
-
// last preceding `cd <path>` segment (see `CommandSegment`'s own doc
|
|
44
|
-
// comment for the composition rules and its own not-covered list, which
|
|
45
|
-
// is narrower in places than `targetDir`/`targetBase` above — e.g. no
|
|
46
|
-
// `VAR=value cd <path>` tolerance). `normalizeCommand`'s own `targetDir`/
|
|
47
|
-
// `targetBase` are UNCHANGED by this addition — byte-identical in every
|
|
48
|
-
// pre-existing test.
|
|
49
|
-
//
|
|
50
|
-
// UPDATE (task `98ad072f`, T-003, this run): `segmentViewOf` /
|
|
51
|
-
// `CommandSegment` — NOT `normalizeCommand`'s own `targetDir`/`targetBase`
|
|
52
|
-
// above, which stay exactly as unwired as before this slice — IS now
|
|
53
|
-
// wired to a gate: `src/runtime/intercept.ts`'s `attributeTriggerSegments`
|
|
54
|
-
// re-tests a matched policy's own `bash_match` regex against each
|
|
55
|
-
// segment's `text`, and, for a policy whose `requires:` uses
|
|
56
|
-
// `${REPO}`/`${BRANCH}`/`at_head`, resolves those builtins (and
|
|
57
|
-
// `currentHeadSha`) from the trigger-satisfying segment's
|
|
58
|
-
// `effectiveTarget` instead of the event's cwd — lazily, memoised per
|
|
59
|
-
// resolved path, never touching `resolverGit`/`riskContext`. Trust in
|
|
60
|
-
// `effectiveTarget` is UNIFORM — no distinction between a segment's own
|
|
61
|
-
// explicit target and one inherited from a preceding `cd` (orchestrator
|
|
62
|
-
// decision D-010: an initial revision distinguished the two and was
|
|
63
|
-
// rejected — bash itself draws no such distinction, a `cd <B> && <verb>`
|
|
64
|
-
// chain genuinely runs `<verb>` inside B). See that module's own comments
|
|
65
|
-
// (`attributeTriggerSegments`, `resolveAttributedContexts`) for the
|
|
66
|
-
// consumption rules; this module's OWN extraction and ambiguity rules
|
|
67
|
-
// below are unchanged by being consumed.
|
|
68
|
-
//
|
|
69
|
-
// MEASUREMENT RULE (task 47297478): before measuring THIS module's
|
|
70
|
-
// extraction across builds with an ad-hoc corpus, read the per-arm-gate
|
|
71
|
-
// design in scripts/measure-bash-prefix-parse.mjs — a corpus arm whose
|
|
72
|
-
// baseline never hit proves nothing, and an ungated zero from an ad-hoc
|
|
73
|
-
// corpus has already been wrong three times in a row (b093911d run).
|
|
74
|
-
// Note the tool itself measures ONLY bash-prefix-parse.ts's `cdTarget`
|
|
75
|
-
// extraction; this module's `normalized`/`targetDir`/`targetBase`
|
|
76
|
-
// outputs have no instrument yet, so cross-build claims about them need
|
|
77
|
-
// their own per-arm-gated measurement — that instrument now exists:
|
|
78
|
-
// scripts/measure-command-normalize.mjs (groundwork for task aabbad63, the
|
|
79
|
-
// BOUNDARY_RE bare-`&` fix below). It rebuilds a faithful superset of the
|
|
80
|
-
// reverted fix's 140-form quoted-value regression corpus and gates arm A
|
|
81
|
-
// on bash actually running the verb, per the same never-fold-into-a-zero
|
|
82
|
-
// discipline.
|
|
83
|
-
//
|
|
84
|
-
// UPDATE (task cf3dff51): closes the quoted-shell-boundary residual named
|
|
85
|
-
// in the "DELIBERATELY NOT SUPPORTED" list below (`VAR='a; b' git push` —
|
|
86
|
-
// task 13e55484's pinned gap). A HALT criterion, written before any
|
|
87
|
-
// implementation code, forced the closing mechanism into an ADDITIVE,
|
|
88
|
-
// fully independent third normalisation pass (`normalizeCommandQuoteAware`
|
|
89
|
-
// / `findNextBoundaryQuoteAware`, defined near `findNextBoundary` below)
|
|
90
|
-
// rather than an in-place edit of `BOUNDARY_RE` / `segmentAndCanonicalize`
|
|
91
|
-
// itself. First round of this task built and fully tested the new pass but
|
|
92
|
-
// left it UNWIRED (the wiring — a `policyMatchesEvent` fourth disjunct in
|
|
93
|
-
// `src/runtime/intercept.ts` — touched a file outside that round's
|
|
94
|
-
// assigned scope). The orchestrator EXTENDED the scope for a second round
|
|
95
|
-
// specifically to include that one wiring point (not a general re-open of
|
|
96
|
-
// scope), so `normalizeCommandQuoteAware` IS now `policyMatchesEvent`'s
|
|
97
|
-
// fourth arm, mirroring `normalizeCommandAmpAware`'s own wiring (task
|
|
98
|
-
// aabbad63) exactly — see that function's own doc comment and
|
|
99
|
-
// `src/runtime/intercept.ts`'s `policyMatchesEvent` for the wired shape.
|
|
100
|
-
// UPDATE (task f561e44c): `src/cli/dry-run.ts`'s own, independently-copied
|
|
101
|
-
// raw-OR-normalised-OR-amp-normalised matcher (the `harness dry-run` debug
|
|
102
|
-
// verb, which predicts `policyMatchesEvent`'s verdict without running it)
|
|
103
|
-
// was left out of the wiring above (named residual, not silently left
|
|
104
|
-
// implicit) and, as a result, under-predicted the quote-aware family. This
|
|
105
|
-
// follow-up task added the same fourth OR-arm to that matcher too, so
|
|
106
|
-
// `harness dry-run` and `policy intercept` now agree on this family — see
|
|
107
|
-
// that file's own comment for the fix and its parity-fixture test.
|
|
108
|
-
//
|
|
109
|
-
// THE HALT CRITERION (decided and written down before writing the first
|
|
110
|
-
// line of this pass's implementation, mirroring 13e55484's own "ONE-
|
|
111
|
-
// DIRECTIONAL GUARD" structure): quote-aware segmentation MUST be built as
|
|
112
|
-
// a NEW, independent function — never as an in-place edit of the EXISTING,
|
|
113
|
-
// already-wired primary pass (`BOUNDARY_RE` / `findNextBoundary` /
|
|
114
|
-
// `segmentAndCanonicalize`'s behaviour for `normalizeCommand`). If closing
|
|
115
|
-
// this residual is ever found to require editing that existing pass in
|
|
116
|
-
// place instead of adding a new one, HALT and fall back to documentation-
|
|
117
|
-
// only (pin the residual, as this header already does for its NOT-
|
|
118
|
-
// SUPPORTED siblings) rather than risk a two-directional regression. This
|
|
119
|
-
// is not a hypothetical: this task PROVED the trigger before writing any
|
|
120
|
-
// code, by reading `tests/runtime/command-normalize.test.ts`'s pinned
|
|
121
|
-
// "accepted over-block: a quoted assignment inside TEXT after a boundary
|
|
122
|
-
// char now matches" case — `echo "a; VAR='x y' git push"` — which matches
|
|
123
|
-
// TODAY only because `BOUNDARY_RE`'s quote-unawareness un-splits `VAR='x
|
|
124
|
-
// y' git push` into its own segment. An in-place quote-aware edit of the
|
|
125
|
-
// SAME pass would un-split it the other way (merge it back into the outer
|
|
126
|
-
// `echo "..."` segment, which is not itself a recognised invocation) and
|
|
127
|
-
// flip that already-accepted, already-WIRED pin from match to no-match —
|
|
128
|
-
// a genuine lost match under this module's own no-lost-matches discipline
|
|
129
|
-
// (see AMP_BOUNDARY_RE's own comment for why that discipline exists at
|
|
130
|
-
// all), not merely a lost false positive. Building a separate, additive
|
|
131
|
-
// pass instead means the existing primary pass — and every pin resting on
|
|
132
|
-
// it, including that exact one — stays byte-for-byte untouched; see
|
|
133
|
-
// `normalizeCommandQuoteAware`'s own comment for the differential
|
|
134
|
-
// measurement that confirms this by construction rather than by
|
|
135
|
-
// inspection alone.
|
|
136
|
-
//
|
|
137
|
-
// THE TRIGGER-MATCHING GAP THIS CLOSES: every `bash_match` policy trigger
|
|
138
|
-
// (`src/cli/init/templates.ts`, `docs/examples/full-manifest.yaml`) is a
|
|
139
|
-
// single regex tested against the UNPARSED command string, anchored on a
|
|
140
|
-
// shell boundary (`^ \n ; | && (`) followed by optional `VAR=value`
|
|
141
|
-
// tokens and a literal head token — `git` (most triggers), `gh` (`gh pr
|
|
142
|
-
// merge`/`gh pr create`), `npm` (`npm publish`), or `harness` (the
|
|
143
|
-
// kill-switch verbs). That anchor is exact-spelling brittle: a wrapper
|
|
144
|
-
// binary, a git global option other than `-C`, or even a second space
|
|
145
|
-
// between the head token and its subcommand defeats the match silently —
|
|
146
|
-
// the policy is skipped, no ledger query happens, no audit signal at all.
|
|
147
|
-
// Measured bypasses closed by this module (task `432db3d3`, run
|
|
148
|
-
// `2026-07-28-nongit-trigger-wrappers`, D-001): `env gh pr merge`, `env -C
|
|
149
|
-
// <dir> gh pr merge`, `nice gh pr merge`, `gh pr merge` (double space),
|
|
150
|
-
// `env gh pr create`, `env npm publish`, `nice npm publish`, `env harness
|
|
151
|
-
// pause`, `nice harness pause`, `command harness pause`, `env -C <dir>
|
|
152
|
-
// harness pause` — the exact one-word-wrapper class already fixed for
|
|
153
|
-
// `git` in the prior run, reproduced against `gh`/`npm`/`harness` because
|
|
154
|
-
// the head-token condition at the end of `canonicalizeSegment` checked
|
|
155
|
-
// literal equality with `git` and nothing else.
|
|
156
|
-
//
|
|
157
|
-
// CURRENT RULES — trigger normalisation (peeled/canonicalised into
|
|
158
|
-
// `normalized`):
|
|
159
|
-
// - Leading `VAR=value` tokens (any number, in sequence).
|
|
160
|
-
// - `env`, including `-C <dir>` / `--chdir <dir>` / `-C<dir>` /
|
|
161
|
-
// `--chdir=<dir>` and its own `VAR=value` arguments. The flag-NAME
|
|
162
|
-
// enumeration (which flags exist, not their VALUES) is IMPORTED from
|
|
163
|
-
// `read-only-bash.ts`'s `ENV_LEADING_FLAGS` / `ENV_VALUE_FLAGS` /
|
|
164
|
-
// `ENV_SPLIT_STRING_FLAGS` rather than hand-rolled here a second
|
|
165
|
-
// time, so the two peelers cannot independently drift on which
|
|
166
|
-
// flags exist; `read-only-bash.ts`'s generic "any OTHER glued
|
|
167
|
-
// `--long=value` flag" catch-all is mirrored here too. Each module
|
|
168
|
-
// keeps its own decision about what a recognised flag MEANS: this
|
|
169
|
-
// module additionally needs the VALUE of `-C`/`--chdir` to extract
|
|
170
|
-
// `targetDir`, which `read-only-bash.ts` never needs, so that
|
|
171
|
-
// glued-value parsing (`-C<dir>`, `--chdir=<dir>`) stays local here.
|
|
172
|
-
// - `command` (its own flags, e.g. `-p`/`-v`/`-V`, and `--`).
|
|
173
|
-
// - `nice`, including `-n <n>` / `-n<n>` / `--adjustment=<n>`, and the
|
|
174
|
-
// bare `-<n>` / `+<n>` forms — `nice -10 cmd` (increment glued
|
|
175
|
-
// straight to the leading dash, no `n`) is `nice(1)`'s PRIMARY
|
|
176
|
-
// documented spelling.
|
|
177
|
-
// - `sudo` / `doas` (their own user/group/config flags — see
|
|
178
|
-
// `SUDO_VALUE_FLAGS` / `DOAS_VALUE_FLAGS`), `time` (`-o`/`-f`/
|
|
179
|
-
// `--output`/`--format` plus boolean flags), `timeout` (its own flags
|
|
180
|
-
// PLUS the mandatory leading DURATION positional — see
|
|
181
|
-
// `peelTimeout`), `stdbuf` (`-i`/`-o`/`-e`, always value-taking), and
|
|
182
|
-
// `setsid` (`-w`/`-c`/`-f`, boolean). NOT `xargs` — deliberately
|
|
183
|
-
// excluded, see NOT SUPPORTED below.
|
|
184
|
-
// - A git invocation's own global options: `-C <dir>`, `-c <k=v>`,
|
|
185
|
-
// `--git-dir[= ]<dir>`, `--work-tree[= ]<dir>`, `--no-pager`,
|
|
186
|
-
// `-p`/`--paginate`, `--exec-path[=<path>]`, `--namespace[= ]<ns>`,
|
|
187
|
-
// `--literal-pathspecs`, `--no-replace-objects` — dropped so the
|
|
188
|
-
// subcommand becomes adjacent to the literal `git` token, and the
|
|
189
|
-
// whitespace between them collapses to exactly one space.
|
|
190
|
-
// - The `git` token itself may be path-qualified (`/usr/bin/git`,
|
|
191
|
-
// `./git`, any `\S*/git`), matched by BASENAME (mirrors the existing
|
|
192
|
-
// `deny-kill-switch-bypass` regex's own `(?:npx\s+|\S*/)?harness`
|
|
193
|
-
// shape). The canonicalised output always writes the literal `git`,
|
|
194
|
-
// regardless of how the invocation spelled the binary.
|
|
195
|
-
// - THE CLOSED HEAD-TOKEN SET (D-001, run
|
|
196
|
-
// 2026-07-28-nongit-trigger-wrappers): after the SAME wrapper-peeling
|
|
197
|
-
// loop above, the head token is checked against `git` OR the three
|
|
198
|
-
// other literal head tokens this module covers — `gh` (`gh pr merge`
|
|
199
|
-
// / `gh pr create`), `npm` (`npm publish`), and `harness` (the
|
|
200
|
-
// kill-switch verbs). `git` alone gets the git-specific global-option
|
|
201
|
-
// dropping described above; the other three get wrapper peeling PLUS
|
|
202
|
-
// whitespace collapsing across the REST OF THE SEGMENT (fix round 2,
|
|
203
|
-
// finding F2 — collapsing only the head-to-next-token gap, the
|
|
204
|
-
// original scope, still let an interior whitespace run defeat a
|
|
205
|
-
// multi-word trigger like `gh pr merge`), no option-dropping (`gh
|
|
206
|
-
// -R`, `npm --loglevel`, and any other tool-specific flag stay
|
|
207
|
-
// UNSUPPORTED — see below — because a flag inserted between the head
|
|
208
|
-
// token and its subcommand tokens is not looked past). Matched by
|
|
209
|
-
// EXACT literal equality, not basename like `git` — a path-qualified
|
|
210
|
-
// `gh`/`npm`/`harness` invocation is out of scope for this set (named
|
|
211
|
-
// residual below); `harness`'s own trigger regex already covers a
|
|
212
|
-
// path-qualified spelling at the RAW-match level (`(?:npx\s+|\S*/)?
|
|
213
|
-
// harness` in `deny-kill-switch-bypass`), so nothing is lost there
|
|
214
|
-
// specifically. This is a CLOSED set, not a general "peel wrappers
|
|
215
|
-
// for any head token" rule.
|
|
216
|
-
// - SHIPPED BUT NOT COVERED (fix round 2, finding F1 — corrects an
|
|
217
|
-
// inaccurate claim in the prior version of this comment, which said
|
|
218
|
-
// this module's four head tokens were "the head tokens shipped
|
|
219
|
-
// policies actually gate today"): a shipped `bash_match` trigger
|
|
220
|
-
// actually keys on EIGHT distinct COMMAND-NAME head tokens, not four —
|
|
221
|
-
// plus one HEADLESS alternative with no command name at all:
|
|
222
|
-
// `deny-session-env-strip`'s bare `<SESSION_VAR>=` empty-assignment
|
|
223
|
-
// form, whose wrapped spelling (`nice CLAUDE_SESSION_ID= npm publish`)
|
|
224
|
-
// is equally out of this module's reach, because the peel loop consumes
|
|
225
|
-
// the assignment as an ordinary `VAR=value` prefix (measured
|
|
226
|
-
// 2026-07-28, pinned alongside the four below).
|
|
227
|
-
// `deny-session-env-strip` also keys on `env` (`env ... -u <VAR>` /
|
|
228
|
-
// `env ... --unset <VAR>`) and `unset` (`unset <VAR>...`);
|
|
229
|
-
// `deny-pause-sentinel-forgery` also keys on `tee` and `cp`. None of
|
|
230
|
-
// these four are in the closed set above, and adding them is not
|
|
231
|
-
// simply "the same kind of change again":
|
|
232
|
-
// - `env` is STRUCTURALLY unreachable by this module's own
|
|
233
|
-
// architecture, not merely unimplemented: the wrapper-peeling
|
|
234
|
-
// loop treats `env` ONLY as a pass-through wrapper hunting for a
|
|
235
|
-
// `git`/`gh`/`npm`/`harness` invocation behind it, so it ALWAYS
|
|
236
|
-
// calls `peelEnv` and consumes `env`'s OWN `-u`/`--unset` flags
|
|
237
|
-
// (and their VALUES) as part of that hunt — by the time `headTok`
|
|
238
|
-
// is checked, the very `-u <VAR>` text `deny-session-env-strip`'s
|
|
239
|
-
// trigger keys on has already been peeled away. Recognising
|
|
240
|
-
// `env` as ITSELF a head token would need the SAME literal token
|
|
241
|
-
// to get two different treatments depending on WHY it is there —
|
|
242
|
-
// an architecture change, not a set-membership change — and is
|
|
243
|
-
// out of scope for this closed-set run. Measured, still-open
|
|
244
|
-
// bypass: `nice env -u CLAUDE_SESSION_ID ls` (pinned as a
|
|
245
|
-
// documented ceiling, not silently left to a comment).
|
|
246
|
-
// - `unset` is simply not one of the wrapper-peeling loop's
|
|
247
|
-
// recognised names at all (unlike `env`, there is no
|
|
248
|
-
// `peelUnset`), so a wrapped `unset` invocation is not looked
|
|
249
|
-
// past either. Measured, still-open bypass: `nice unset
|
|
250
|
-
// CLAUDE_SESSION_ID`.
|
|
251
|
-
// - `tee` / `cp` are never wrapper-peeled or recognised as a head
|
|
252
|
-
// token by this module at all; `deny-pause-sentinel-forgery`'s
|
|
253
|
-
// own raw regex is the only thing that ever catches them.
|
|
254
|
-
// Measured, still-open bypasses: `nice tee /tmp/.harness-paused`,
|
|
255
|
-
// `nice cp a /tmp/.harness-paused`.
|
|
256
|
-
// A future policy gating some OTHER head token (one of these four, or
|
|
257
|
-
// any other) stays uncovered by this module too — named residual, no
|
|
258
|
-
// follow-up filed unless a real consumer appears. See
|
|
259
|
-
// `tests/runtime/bash-match-head-token-drift.test.ts` for the guard
|
|
260
|
-
// that couples this module's covered set to what FULL_TEMPLATE's
|
|
261
|
-
// shipped `bash_match` policies actually key on today.
|
|
262
|
-
//
|
|
263
|
-
// CURRENT RULES — target-directory extraction (`targetDir`/`targetBase`;
|
|
264
|
-
// UNCONSUMED — see STATUS above):
|
|
265
|
-
// - The effective target directory of the command's git invocation(s),
|
|
266
|
-
// from (in priority order per invocation) the git invocation's own
|
|
267
|
-
// `-C` / `--work-tree` / `--git-dir` (parent directory when the path
|
|
268
|
-
// ends in `.git`), the wrapping `env -C` / `--chdir` on the SAME
|
|
269
|
-
// invocation, or — only when NO invocation named one explicitly — a
|
|
270
|
-
// leading `cd <dir> &&|;` prefix (delegated to `bash-prefix-
|
|
271
|
-
// parse.ts`, which already parses that idiom). A `~`-prefixed value
|
|
272
|
-
// is treated as though no target were named AT ALL: this module does
|
|
273
|
-
// not expand `~`, and letting it resolve against the caller's cwd
|
|
274
|
-
// would produce a CONFIDENTLY WRONG answer (an unrelated repo found
|
|
275
|
-
// by walking up from a bogus path) instead of the documented "no
|
|
276
|
-
// target" fallback.
|
|
277
|
-
// - `targetBase`: when exactly one git invocation named an explicit
|
|
278
|
-
// target (or several agree on the SAME one) and it is a RELATIVE
|
|
279
|
-
// path, this names the directory it is relative to, when this
|
|
280
|
-
// module saw one — the wrapping `env -C` on the SAME invocation
|
|
281
|
-
// (highest priority), else a leading `cd <dir> &&|;` prefix (`cd
|
|
282
|
-
// <repo> && git -C src status` resolves `src` against `<repo>`, not
|
|
283
|
-
// a caller's own cwd). `null` when neither is present — a
|
|
284
|
-
// hypothetical future consumer resolves `targetDir` against its own
|
|
285
|
-
// cwd instead.
|
|
286
|
-
// - When a command names MORE THAN ONE git invocation, `targetDir` (and
|
|
287
|
-
// `targetBase`) are populated only when every invocation AGREES: all
|
|
288
|
-
// of them name the SAME explicit target, or NONE of them do (falling
|
|
289
|
-
// through to the leading-`cd` case above). A command mixing a bare
|
|
290
|
-
// invocation with an explicitly-targeted one — `git -C <B> log &&
|
|
291
|
-
// git push` — or naming two DIFFERENT explicit targets, is AMBIGUOUS
|
|
292
|
-
// and `targetDir` is `null`.
|
|
293
|
-
// - "Every invocation agrees" is ALSO broken by a non-git command that
|
|
294
|
-
// shares the chain: a command-starting segment (follows
|
|
295
|
-
// `&&`/`;`/`\n`/`(`, or is the first segment — never a bare `|`,
|
|
296
|
-
// which stays in the SAME directory) that is NEITHER a git
|
|
297
|
-
// invocation NOR the recognised leading `cd` prefix forces
|
|
298
|
-
// `targetDir` to `null` too, the same conservative fallback used for
|
|
299
|
-
// git-vs-git disagreement — measured case: `git -C <B> rev-parse
|
|
300
|
-
// HEAD && gh pr merge`, where `-C` scopes only the ONE git call it
|
|
301
|
-
// decorates and `gh pr merge` genuinely runs at the real, unaffected
|
|
302
|
-
// cwd. The leading `cd` prefix stays exempt on purpose: unlike `-C`,
|
|
303
|
-
// `cd` genuinely persists for the rest of the chain, so `cd <B> &&
|
|
304
|
-
// gh pr create` really does run against B — only a PER-INVOCATION
|
|
305
|
-
// override (`-C`, `--work-tree`, `--git-dir`, wrapping `env -C`) can
|
|
306
|
-
// leak across an unrelated later command; a persistent `cd` cannot,
|
|
307
|
-
// by construction.
|
|
308
|
-
//
|
|
309
|
-
// DELIBERATELY NOT SUPPORTED, and out of reach of ANY string-level
|
|
310
|
-
// approach (see `CHANGELOG.md` task `2cc73f55`, decision D-005):
|
|
311
|
-
// - `sh -c '...'` / `bash -c '...'`: the wrapped command lives inside a
|
|
312
|
-
// single string argument. This module does not parse into it — note
|
|
313
|
-
// that such a command usually still matches a `bash_match` regex
|
|
314
|
-
// anyway, because the regex scans the RAW command string without
|
|
315
|
-
// respecting quoting, so a shell boundary (`&&`, `;`, ...) inside the
|
|
316
|
-
// quoted string is still "seen".
|
|
317
|
-
// - Heredocs, `eval`, base64-decoded payloads, or a script file the
|
|
318
|
-
// agent writes and then executes.
|
|
319
|
-
// - `pushd`/`popd`, and a `cd` inside a nested subshell
|
|
320
|
-
// (`(cd X && ...)`) — mirrors `bash-prefix-parse.ts`'s own scope.
|
|
321
|
-
// - CLOSED (was: a shell-boundary character INSIDE a quoted assignment
|
|
322
|
-
// VALUE — `VAR='a; b' git push`, task 13e55484's pinned residual, no
|
|
323
|
-
// longer belongs in a NOT-SUPPORTED list). Task cf3dff51: a quote-
|
|
324
|
-
// aware THIRD normalisation pass, `normalizeCommandQuoteAware`
|
|
325
|
-
// (defined near `findNextBoundary` below), built as a new, additive
|
|
326
|
-
// pass rather than an in-place edit of `BOUNDARY_RE` /
|
|
327
|
-
// `segmentAndCanonicalize` (see the module header's HALT CRITERION
|
|
328
|
-
// paragraph and that function's own comment for why), now wired as
|
|
329
|
-
// `policyMatchesEvent`'s FOURTH arm (`src/runtime/intercept.ts`,
|
|
330
|
-
// mirroring `normalizeCommandAmpAware`'s own wiring, task aabbad63).
|
|
331
|
-
// Closes `;`, `|`, `&&`, `(`, and a literal newline, each with an
|
|
332
|
-
// internal whitespace split inside the quoted value (the exact
|
|
333
|
-
// `VAR='a; b' git push` shape) — measured 12/12 at the real
|
|
334
|
-
// `runInterceptCli` hook entry point (isolated harness home,
|
|
335
|
-
// PATH-shim-verified bash execution, two gated verbs). Over-block
|
|
336
|
-
// surface: MEASURED, not assumed, against an adversarial probe —
|
|
337
|
-
// every ADDITIONAL match this pass adds beyond raw /
|
|
338
|
-
// `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive
|
|
339
|
-
// (a real invocation bash would actually execute); no NEW over-block
|
|
340
|
-
// CLASS distinct from `consumeAssignment`'s own, already-reviewed
|
|
341
|
-
// precision limits (the "master false positive" class already pinned
|
|
342
|
-
// elsewhere in the test file) was found. See `normalizeCommandQuoteAware`'s
|
|
343
|
-
// own doc comment for the full mechanism and reasoning. CLOSED (task
|
|
344
|
-
// f561e44c): `src/cli/dry-run.ts`'s independently-copied matcher was
|
|
345
|
-
// initially not extended to try this fourth form — named residual — but
|
|
346
|
-
// that follow-up task added the same fourth OR-arm there too, see that
|
|
347
|
-
// file's own comment.
|
|
348
|
-
// - A quoted assignment VALUE containing the literal word `git`/`gh`/
|
|
349
|
-
// `npm`/`harness` as its own token (`VAR='a git b' git push`): the
|
|
350
|
-
// continuation's one-directional guard abandons rather than risk
|
|
351
|
-
// swallowing a real head token (see `consumeAssignment`), leaving
|
|
352
|
-
// the byte-exact pre-task behaviour on those spellings.
|
|
353
|
-
// - Quoted directory arguments containing whitespace (e.g. `git -C
|
|
354
|
-
// '/path with spaces' status`): the tokeniser splits on whitespace
|
|
355
|
-
// without quote-awareness, so such a case falls through to "no git
|
|
356
|
-
// invocation found here" and the segment is left unchanged. Safe
|
|
357
|
-
// (never a false positive), just not one of the covered shapes.
|
|
358
|
-
// - `xargs git status`: unlike `env`/`command`/`nice`/`sudo`/`doas`/...,
|
|
359
|
-
// `xargs`'s own argv is not simply "the command to run" — it appends
|
|
360
|
-
// stdin lines as trailing arguments and may invoke the wrapped
|
|
361
|
-
// command MULTIPLE times, or zero, depending on stdin. Different
|
|
362
|
-
// enough semantics that peeling it the same way would be misleading,
|
|
363
|
-
// not just incomplete, so it is deliberately excluded, not merely
|
|
364
|
-
// missed.
|
|
365
|
-
// - A quoted git subcommand (`git "status"`): the tokeniser sees the
|
|
366
|
-
// literal token `"status"` (quotes included) and canonicalises to
|
|
367
|
-
// `git "status"`, which does not satisfy a `bash_match` regex
|
|
368
|
-
// expecting a bare word boundary. The same class of problem as the
|
|
369
|
-
// whitespace-in-quoted-path gap above.
|
|
370
|
-
// - Backtick command substitution wrapping the real invocation, e.g.
|
|
371
|
-
// `` echo `env -C /tmp git status` ``: this module canonicalises the
|
|
372
|
-
// OUTER command only and does not recurse into a substitution's
|
|
373
|
-
// contents — the same boundary `sh -c` stops at, above.
|
|
374
|
-
// - `$(...)` command substitution is NOT in this list — see the
|
|
375
|
-
// "accidentally covered" note right below. Do not read its absence
|
|
376
|
-
// here as "also open"; it genuinely blocks today, pinned by a test.
|
|
377
|
-
// - CLOSED (task d03af8f6, review round 3): `exec git status` and
|
|
378
|
-
// `nohup git status` used to sit in this list. Both are now peeled
|
|
379
|
-
// the same boolean-flag-only way as `setsid` (`peelExec`/`peelNohup`,
|
|
380
|
-
// part of the shared `peelWrapperPrefixes` loop) — added while
|
|
381
|
-
// rebuilding `src/runtime/deletion-target-resolve.ts`'s own
|
|
382
|
-
// recognition on top of this module's peelers so the deletion gate
|
|
383
|
-
// could see past `exec rm -rf ...` / `nohup rm -rf ... &`, and added
|
|
384
|
-
// HERE (not only there) so this module's own `git`/`gh`/`npm`/
|
|
385
|
-
// `harness` trigger recognition benefits too, per the orchestrator
|
|
386
|
-
// decision for that task. `exec`'s own rare value-taking flag (`-a
|
|
387
|
-
// name`) is NOT modelled — treated as boolean like every other
|
|
388
|
-
// unrecognised flag this loop's generic branch consumes, the same
|
|
389
|
-
// approximation `setsid`'s three flags already use.
|
|
390
|
-
// - Eight more measured-bypass spellings, none of them peeled wrapper
|
|
391
|
-
// prefixes or a recognised `git` spelling: `ionice -c3 git status`,
|
|
392
|
-
// `flock /tmp/l git status`, `script -q /dev/null git status`, `chrt
|
|
393
|
-
// -b 0 git status`, `taskset -c 0 git status`, `\git status` (the
|
|
394
|
-
// backslash defeats `GIT_TOKEN_RE`, which requires the bare
|
|
395
|
-
// basename), `"git" status` (a QUOTED binary name — the mirror image
|
|
396
|
-
// of the quoted-subcommand gap above), and `env -S "git status"` (the
|
|
397
|
-
// split-string flag hands the rest of the argv to a fresh, opaque
|
|
398
|
-
// re-parse this module deliberately does not follow, per `peelEnv`'s
|
|
399
|
-
// own comment).
|
|
400
|
-
// - `gh`'s / `npm`'s own tool-specific flags inserted BETWEEN the head
|
|
401
|
-
// token and its subcommand: `gh -R owner/repo pr merge`, `gh --repo
|
|
402
|
-
// owner/repo pr create`, `npm --loglevel=silent publish`, `npm
|
|
403
|
-
// --registry <url> publish`. Only wrapper prefixes (`env`, `nice`,
|
|
404
|
-
// `command`, ...) and whitespace collapsing across the rest of the
|
|
405
|
-
// segment (see the closed head-token-set rule above) are peeled for
|
|
406
|
-
// these three heads — a flag belonging to `gh`/`npm` itself is never
|
|
407
|
-
// recognised or skipped, so it defeats the match the same way an
|
|
408
|
-
// unrecognised git flag would if `peelGitGlobalOptions` did not know
|
|
409
|
-
// its name.
|
|
410
|
-
// - A path-qualified `gh`/`npm`/`harness` invocation (`/usr/local/bin/gh
|
|
411
|
-
// pr merge`, `./node_modules/.bin/npm publish`): the closed
|
|
412
|
-
// head-token-set check is EXACT literal equality, not basename like
|
|
413
|
-
// `GIT_TOKEN_RE`. `harness`'s own trigger regex already covers a
|
|
414
|
-
// path-qualified spelling at the raw-match level, so nothing is lost
|
|
415
|
-
// there specifically; `gh`/`npm` genuinely have no coverage for this
|
|
416
|
-
// shape.
|
|
417
|
-
//
|
|
418
|
-
// KNOWN OVER-MATCHING (FALSE POSITIVE) CLASS — `command -v NAME` / `sudo
|
|
419
|
-
// -l [NAME]` (fix round 2, finding F5; doc-only, no behaviour change):
|
|
420
|
-
// both are INTROSPECTION forms — `command -v NAME` prints what `NAME`
|
|
421
|
-
// resolves to without running it, `sudo -l [NAME]` lists (without
|
|
422
|
-
// running) whether `NAME` is permitted — but this module's wrapper
|
|
423
|
-
// peeling treats `-v` (an otherwise-unrecognised `command` flag) and `-l`
|
|
424
|
-
// (an otherwise-unrecognised `sudo` flag) as ordinary BOOLEAN flags to
|
|
425
|
-
// skip past, exactly like any other flag either wrapper legitimately
|
|
426
|
-
// takes before its real, EXECUTED payload. `command -v harness pause`
|
|
427
|
-
// therefore canonicalises to `harness pause` and DENIES via
|
|
428
|
-
// `deny-kill-switch-bypass` even though nothing was actually paused
|
|
429
|
-
// (measured). This predates this run — `sudo -l git status` was already
|
|
430
|
-
// a false positive under the git-only normaliser — this run only widens
|
|
431
|
-
// it to also cover `gh`/`npm`/`harness`. Fail-CLOSED direction (an
|
|
432
|
-
// operator gets an unnecessary deny, never a missed real gate), so left
|
|
433
|
-
// as-is rather than special-cased; noted here so it is not mistaken for
|
|
434
|
-
// a security hole if reported.
|
|
435
|
-
//
|
|
436
|
-
// SAME CLASS, second member (task 13e55484, review round 1): a QUOTED
|
|
437
|
-
// ASSIGNMENT INSIDE TEXT at a segment-start position now over-matches.
|
|
438
|
-
// `BOUNDARY_RE` splits segments with no quote awareness, so in
|
|
439
|
-
// `echo "a; VAR='x y' git push"` the text after the `;` becomes its own
|
|
440
|
-
// segment, the assignment continuation normalises it to `git push`, and
|
|
441
|
-
// the push gate DENIES a command that only PRINTS the spelling
|
|
442
|
-
// (measured; master allowed it). Fail-closed direction, accepted and
|
|
443
|
-
// pinned in the test file as a boundary assertion — the practical cost
|
|
444
|
-
// is that documenting or testing that bypass class through a Bash call
|
|
445
|
-
// trips the gate, the same cost the dbc6d303 quoted/heredoc class
|
|
446
|
-
// already carries.
|
|
447
|
-
//
|
|
448
|
-
// SAME CLASS, third member (task aabbad63, this run's own amp-aware
|
|
449
|
-
// second pass): a WIDENING of the second member above, not a new class —
|
|
450
|
-
// `AMP_BOUNDARY_RE` is quote-unaware in exactly the same way `BOUNDARY_RE`
|
|
451
|
-
// is, so a bare `&` INSIDE a quoted string is now also a segment
|
|
452
|
-
// boundary, and a wrapper spelling sitting behind it can be peeled and
|
|
453
|
-
// canonicalised as though it were a real invocation. Measured on a
|
|
454
|
-
// 160-form quoted-text corpus arbitrated by real bash (PATH-shimmed
|
|
455
|
-
// verbs + a marker file the verb would touch if it actually ran): 144
|
|
456
|
-
// spellings of `<text> & <wrapper> <gated verb>` INSIDE a quoted string
|
|
457
|
-
// now match where the base (pre-aabbad63) build did not, and bash ran
|
|
458
|
-
// the gated verb in 0 of them — a false alarm on a command that only
|
|
459
|
-
// PRINTS the spelling, never a missed real gate. The wrapper-LESS
|
|
460
|
-
// variant of the same shape (`echo 'a & git push'`) already false-
|
|
461
|
-
// alarmed on the base build 16/16 via the RAW regex alone, because every
|
|
462
|
-
// shipped `bash_match` trigger already anchors on a bare `&` (task
|
|
463
|
-
// `d834a065`) — so this is a WIDENING of an already-accepted over-block
|
|
464
|
-
// class to the wrapped spellings of it, not a new false-positive class
|
|
465
|
-
// this task introduces. The opposite direction, measured on the general
|
|
466
|
-
// (non-adversarial) corpus rather than this quoted-text one: of 276
|
|
467
|
-
// matches this pass gains there, all 151 classifiable ones were TRUE
|
|
468
|
-
// positives, 0 false alarms.
|
|
469
|
-
//
|
|
470
|
-
// `$(...)` command substitution is ACCIDENTALLY covered — not a
|
|
471
|
-
// deliberate feature, and distinct from the backtick case above, which
|
|
472
|
-
// is a genuine, deliberate gap. `BOUNDARY_RE` treats a bare `(` as a
|
|
473
|
-
// shell-boundary token (needed for the leading-command case, `(cd X &&
|
|
474
|
-
// ...)`), and `$(...)`'s opening paren is, character-for-character, the
|
|
475
|
-
// same `(`. So `echo $(env -C /tmp git status)` splits into a segment
|
|
476
|
-
// starting exactly at that `(`, `canonicalizeSegment` finds a real git
|
|
477
|
-
// invocation inside it, and the close paren rides along harmlessly as
|
|
478
|
-
// trailing text after the subcommand (`git status)` still satisfies a
|
|
479
|
-
// `...status\b` regex, since `)` is a non-word character). This is
|
|
480
|
-
// coincidental: `BOUNDARY_RE` was never designed with `$(...)` in mind,
|
|
481
|
-
// and an unrelated future change to it could silently drop this coverage
|
|
482
|
-
// with no test noticing — pinned by a dedicated test for exactly that
|
|
483
|
-
// reason.
|
|
484
|
-
//
|
|
485
|
-
// Above `MAX_NORMALIZE_LENGTH` characters, normalisation is skipped
|
|
486
|
-
// entirely and the command is returned unchanged: `harness policy
|
|
487
|
-
// intercept` runs on every Bash/Edit/Write tool call and `require-
|
|
488
|
-
// preflight-evidence` declares `budget_ms: 1000`, so command SIZE must
|
|
489
|
-
// never be able to drive the hook past its own timeout budget. The RAW
|
|
490
|
-
// command is still tested by `policyMatchesEvent` regardless (raw-OR-
|
|
491
|
-
// normalised construction), so an oversized command only loses the
|
|
492
|
-
// ADDITIONAL normalised-form coverage, never the baseline one.
|
|
493
|
-
// `NormalizedCommand.truncated` reports the skip back to the caller;
|
|
494
|
-
// `runInterceptCli` (`src/cli/policy/intercept.ts`) writes exactly one
|
|
495
|
-
// stderr line when it sees `truncated: true`, keeping this module itself
|
|
496
|
-
// pure and I/O-free (see below) while making the skip observable at the
|
|
497
|
-
// one place that already owns a stderr stream for this event.
|
|
498
|
-
//
|
|
499
|
-
// Bounded, allocation-light, pure string work: no `fs`, no
|
|
500
|
-
// `child_process`, no network — `node:path` is used ONLY for
|
|
501
|
-
// `path.isAbsolute`, a synchronous string check with no I/O. This runs on
|
|
502
|
-
// every Bash/Edit/Write tool call, so it must stay cheap: `findNextBoundary`
|
|
503
|
-
// is a single combined-alternation regex scan rather than one `indexOf`
|
|
504
|
-
// per boundary token (a command with many segments and at least one
|
|
505
|
-
// boundary kind that never occurs anywhere would otherwise degenerate to
|
|
506
|
-
// O(segments × length) — see `findNextBoundary`'s own comment). Never
|
|
507
|
-
// throws: a malformed or unparseable command falls through cleanly and
|
|
508
|
-
// `normalized` is the input unchanged.
|
|
509
|
-
import * as path from "node:path";
|
|
510
|
-
import { parseBashPrefix } from "./bash-prefix-parse.js";
|
|
511
|
-
import { ENV_LEADING_FLAGS, ENV_SPLIT_STRING_FLAGS, ENV_VALUE_FLAGS, } from "./read-only-bash.js";
|
|
512
|
-
const VAR_ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
513
|
-
/**
|
|
514
|
-
* The `git` token itself, matched by BASENAME so a path-qualified
|
|
515
|
-
* invocation (`/usr/bin/git`, `./git`) is recognised too (F4 fix).
|
|
516
|
-
* Anchored to the WHOLE token (not a substring) so `mygit` / `git-foo`
|
|
517
|
-
* still correctly fail to match — `\S*` only ever contributes characters
|
|
518
|
-
* immediately before a literal `/`, never before `git` directly.
|
|
519
|
-
* Exported (fix round 2, finding F3) so
|
|
520
|
-
* `tests/runtime/bash-match-head-token-drift.test.ts` can couple this
|
|
521
|
-
* module's covered set to what FULL_TEMPLATE's shipped `bash_match`
|
|
522
|
-
* policies actually key on today, instead of a test-owned duplicate.
|
|
523
|
-
*/
|
|
524
|
-
export const GIT_TOKEN_RE = /^(?:\S*\/)?git$/;
|
|
525
|
-
/**
|
|
526
|
-
* git global options that this module's `peelGitGlobalOptions` skips over
|
|
527
|
-
* to find the subcommand. Exported so `read-only-bash.ts` can share the same
|
|
528
|
-
* source of truth; each module keeps its own decision about what a matched
|
|
529
|
-
* option means.
|
|
530
|
-
*
|
|
531
|
-
* Flags that consume a value (separate token or glued via `=`): `-C`,
|
|
532
|
-
* `--git-dir`, `--work-tree`, `-c`, `--namespace`.
|
|
533
|
-
*
|
|
534
|
-
* Flags without a value: `--no-pager`, `-p`, `--paginate`, `--exec-path`,
|
|
535
|
-
* `--literal-pathspecs`, `--no-replace-objects`.
|
|
536
|
-
*/
|
|
537
|
-
/**
|
|
538
|
-
* git global options that take a value, excluding `-c` for security.
|
|
539
|
-
* `-c KEY=VALUE` can inject arbitrary git config that executes code
|
|
540
|
-
* (core.fsmonitor, core.pager, core.editor, protocol.*.allow run
|
|
541
|
-
* arbitrary programs); fail-closed by not skipping it. read-only-bash.ts
|
|
542
|
-
* uses this set to skip only the safe value-taking options.
|
|
543
|
-
*
|
|
544
|
-
* Flags: `-C`, `--git-dir`, `--work-tree`, `--namespace` (recognized both
|
|
545
|
-
* standalone and glued with `=`).
|
|
546
|
-
*/
|
|
547
|
-
export const GIT_GLOBAL_VALUE_TAKING_FLAGS = new Set([
|
|
548
|
-
"-C", "--git-dir", "--work-tree", "--namespace",
|
|
549
|
-
]);
|
|
550
|
-
/**
|
|
551
|
-
* git global options that take no value.
|
|
552
|
-
* Flags: `--no-pager`, `-p`, `--paginate`, `--exec-path`,
|
|
553
|
-
* `--literal-pathspecs`, `--no-replace-objects`.
|
|
554
|
-
*/
|
|
555
|
-
export const GIT_GLOBAL_NO_VALUE_FLAGS = new Set([
|
|
556
|
-
"--no-pager", "-p", "--paginate",
|
|
557
|
-
"--exec-path",
|
|
558
|
-
"--literal-pathspecs", "--no-replace-objects",
|
|
559
|
-
]);
|
|
560
|
-
/**
|
|
561
|
-
* The three OTHER head tokens this module covers (D-001, run
|
|
562
|
-
* 2026-07-28-nongit-trigger-wrappers) — `gh` (`gh pr merge` / `gh pr
|
|
563
|
-
* create`), `npm` (`npm publish`), `harness` (the kill-switch verbs). A
|
|
564
|
-
* closed set, deliberately NOT a general "any head token" rule (see the
|
|
565
|
-
* module header): matched by EXACT literal equality, unlike `GIT_TOKEN_RE`'s
|
|
566
|
-
* basename match, so a path-qualified spelling of any of these three stays
|
|
567
|
-
* out of scope (named residual, module header NOT-SUPPORTED list). This is
|
|
568
|
-
* NOT the complete set of head tokens a shipped `bash_match` trigger keys
|
|
569
|
-
* on — see the module header's "SHIPPED BUT NOT COVERED" paragraph (fix
|
|
570
|
-
* round 2, finding F1) for the four that are NOT in this set (`env`,
|
|
571
|
-
* `unset`, `tee`, `cp`) and why. Exported (fix round 2, finding F3) for the
|
|
572
|
-
* same drift-guard reason as `GIT_TOKEN_RE` above.
|
|
573
|
-
*/
|
|
574
|
-
export const NON_GIT_HEAD_TOKENS = new Set(["gh", "npm", "harness"]);
|
|
575
|
-
/**
|
|
576
|
-
* Above this length, normalisation is skipped entirely and the command
|
|
577
|
-
* is returned unchanged (F3 fix — see the module header for why command
|
|
578
|
-
* SIZE must be bounded even after `findNextBoundary` was made linear).
|
|
579
|
-
* 100k characters comfortably covers any command a human or agent would
|
|
580
|
-
* plausibly type, while keeping the worst case a small constant instead
|
|
581
|
-
* of scaling with whatever ends up pasted into a single Bash call.
|
|
582
|
-
* Exported (G4 fix, review round 2) so a caller reporting the skip (see
|
|
583
|
-
* `NormalizedCommand.truncated`) can reference the same number instead of
|
|
584
|
-
* hand-copying it into a diagnostic message.
|
|
585
|
-
*/
|
|
586
|
-
export const MAX_NORMALIZE_LENGTH = 100_000;
|
|
587
|
-
/**
|
|
588
|
-
* Shell boundary tokens a `bash_match` regex can anchor on, expressed as
|
|
589
|
-
* ONE alternation so `findNextBoundary` does a single regex scan per
|
|
590
|
-
* remaining span instead of one `indexOf` per token (F3 fix, review
|
|
591
|
-
* round 2026-07-27). Order among the alternatives does not affect
|
|
592
|
-
* correctness: no two of these tokens can start at the same string
|
|
593
|
-
* position (only `&&` is multi-character, and no other alternative
|
|
594
|
-
* starts with `&`), so there is no tie for the regex engine's leftmost-
|
|
595
|
-
* match rule to break.
|
|
596
|
-
*
|
|
597
|
-
* DELIBERATELY MISSING, STILL: single `&`, even though bash starts a new
|
|
598
|
-
* command after it. Adding it HERE was tried and REVERTED after
|
|
599
|
-
* measurement: this regex is quote-unaware, `&` inside a quoted
|
|
600
|
-
* assignment value is common, and the split lands mid-quote, so the
|
|
601
|
-
* value continuation never engages and the wrapper is never peeled.
|
|
602
|
-
* Measured against the pre-change build with a passing positive
|
|
603
|
-
* control: 140 of 140 sampled `<wrapper> FOO='a&b' <gated verb>`
|
|
604
|
-
* spellings LOST a gate they previously held, including the
|
|
605
|
-
* `operator_only` kill-switch deny. That evidence is why THIS alphabet
|
|
606
|
-
* stays byte-identical rather than growing a bare `&` alternative
|
|
607
|
-
* directly — see `scripts/measure-command-normalize.mjs`'s arm A for the
|
|
608
|
-
* regression corpus that keeps proving it stays byte-identical.
|
|
609
|
-
*
|
|
610
|
-
* TASK aabbad63 (this run): the bare-`&` family this alphabet cannot see
|
|
611
|
-
* (`A=x&env -C /tmp git status`, `echo hi & nice git status`) is now
|
|
612
|
-
* handled WITHOUT touching this regex at all — by a SECOND, independent
|
|
613
|
-
* boundary alphabet (`AMP_BOUNDARY_RE` below) driving a SECOND
|
|
614
|
-
* normalisation pass (`normalizeCommandAmpAware`) that
|
|
615
|
-
* `policyMatchesEvent` (`src/runtime/intercept.ts`) tries ONLY as a
|
|
616
|
-
* third, additional raw-OR-normalised-OR-amp-normalised disjunct — never
|
|
617
|
-
* a replacement for either of the first two arms. That construction is
|
|
618
|
-
* exactly what avoids re-triggering the 140/140 regression above: every
|
|
619
|
-
* command that matched via the raw test or via THIS (BOUNDARY_RE)
|
|
620
|
-
* normalised form keeps matching through those same two arms, byte-for-
|
|
621
|
-
* byte unchanged; the amp-aware arm can only ADD a match a spelling
|
|
622
|
-
* would otherwise have missed, so a quoted `FOO='a&b'` value that this
|
|
623
|
-
* alphabet already normalises correctly is never at risk of losing that
|
|
624
|
-
* gate to the new arm. See `AMP_BOUNDARY_RE`'s own comment for why it
|
|
625
|
-
* has to be a distinct regex object rather than a mutation of this one,
|
|
626
|
-
* and `normalizeCommandAmpAware`'s comment for why its return type
|
|
627
|
-
* carries no `targetDir`/`targetBase`.
|
|
628
|
-
*/
|
|
629
|
-
const BOUNDARY_RE = /\n|&&|;|\||\(/g;
|
|
630
|
-
/**
|
|
631
|
-
* Second boundary alphabet (task aabbad63): identical to `BOUNDARY_RE`
|
|
632
|
-
* plus a bare `&` — bash's OWN "start a new command here" token, which
|
|
633
|
-
* every shipped `bash_match` trigger regex has treated as one of its own
|
|
634
|
-
* anchor characters since task `d834a065`, but which `BOUNDARY_RE` above
|
|
635
|
-
* has never recognised as a NORMALISATION-time boundary (see its comment
|
|
636
|
-
* for the reverted, measured-140/140-regression attempt to add it there
|
|
637
|
-
* directly).
|
|
638
|
-
*
|
|
639
|
-
* ALTERNATION ORDER IS LOAD-BEARING: `&&` MUST be listed to the LEFT of
|
|
640
|
-
* the bare `&` alternative. JS regex alternation is leftmost-FIRST, not
|
|
641
|
-
* leftmost-longest — with `&` tried before `&&`, every `&&` in a command
|
|
642
|
-
* would tokenise as TWO consecutive one-character `&` boundary MATCHES
|
|
643
|
-
* instead of a single two-character `&&` boundary match.
|
|
644
|
-
*
|
|
645
|
-
* MEASURED, NOT ASSUMED (do not "simplify" the pin below to an
|
|
646
|
-
* end-to-end `.normalized` string assertion): for THIS module's segment-
|
|
647
|
-
* and-rejoin architecture specifically, that reordering is, perhaps
|
|
648
|
-
* counter-intuitively, INVISIBLE in `normalizeCommandAmpAware`'s own
|
|
649
|
-
* output string. Two adjacent single-`&` matches sandwich a genuinely
|
|
650
|
-
* zero-length segment between them (the two `&` characters of a real
|
|
651
|
-
* `&&` are, by definition, adjacent), `canonicalizeSegment("")` is a
|
|
652
|
-
* verbatim no-op, and `parts.join("")` reassembles the exact same bytes
|
|
653
|
-
* either way — verified directly (not merely reasoned about) by
|
|
654
|
-
* temporarily swapping this alternation's order, rebuilding, and
|
|
655
|
-
* diffing `normalizeCommandAmpAware(...).normalized` across several
|
|
656
|
-
* `&&`-bearing commands (including this comment's own `git -C
|
|
657
|
-
* /tmp/repoB status && git -C /tmp/repoB log` example): byte-identical
|
|
658
|
-
* output both ways. So a test asserting on the OUTPUT STRING alone
|
|
659
|
-
* cannot distinguish the two orderings for this architecture — the pin
|
|
660
|
-
* in `tests/runtime/command-normalize.test.ts` therefore execs this
|
|
661
|
-
* regex object DIRECTLY against a `&&`-bearing string and asserts the
|
|
662
|
-
* match text is `"&&"`, not `"&"`, which DOES fail under a swap. The
|
|
663
|
-
* order is still specified exactly as documented above (leftmost-first
|
|
664
|
-
* is real JS semantics, and the internal `segmentAndCanonicalize`
|
|
665
|
-
* bookkeeping this alphabet ALSO drives — `hasAmbiguousNonGitSegment`,
|
|
666
|
-
* discarded by `normalizeCommandAmpAware` but not by a hypothetical
|
|
667
|
-
* future consumer of `segmentAndCanonicalize` under this alphabet —
|
|
668
|
-
* would still see a spurious extra "non-git segment" under the wrong
|
|
669
|
-
* order), it just cannot be pinned through this module's ONE exposed
|
|
670
|
-
* string alone.
|
|
671
|
-
*
|
|
672
|
-
* Used ONLY by `normalizeCommandAmpAware` below, which is consulted ONLY
|
|
673
|
-
* as `policyMatchesEvent`'s third, additional matching arm — see that
|
|
674
|
-
* function's own comment for why its result never carries a `targetDir`/
|
|
675
|
-
* `targetBase`, and `src/runtime/intercept.ts` for the memoised-thunk
|
|
676
|
-
* threading that keeps this off the common (raw-or-normalised-already-
|
|
677
|
-
* matched) path. Exported (mirrors `GIT_TOKEN_RE` / `NON_GIT_HEAD_TOKENS`
|
|
678
|
-
* / `MAX_NORMALIZE_LENGTH` above) solely so the ordering pin test can
|
|
679
|
-
* exec it directly instead of re-declaring an equivalent regex literal
|
|
680
|
-
* that would test JS semantics in general, not THIS source's actual
|
|
681
|
-
* alternation.
|
|
682
|
-
*/
|
|
683
|
-
export const AMP_BOUNDARY_RE = /\n|&&|&|;|\||\(/g;
|
|
684
|
-
/** A target value starting with `~` — not expanded, treated as unparseable (F5). */
|
|
685
|
-
function isTildeTarget(dir) {
|
|
686
|
-
return dir.startsWith("~");
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* A target value `segmentViewOf`'s `CommandSegment.ownTarget` (task
|
|
690
|
-
* `98ad072f` groundwork) treats as unattributable even where the
|
|
691
|
-
* pre-existing per-segment machinery below (`canonicalizeSegment` /
|
|
692
|
-
* `peelGitGlobalOptions` / `peelEnv`) still captures SOME string for the
|
|
693
|
-
* OLD aggregate's `targetDir` — that extraction is UNCHANGED by this check
|
|
694
|
-
* (see the module header's STATUS/UPDATE note): the tokeniser is not
|
|
695
|
-
* quote-aware, so a quoted `-C` / `--work-tree` / `--git-dir` / `env -C`
|
|
696
|
-
* value is captured WITH its quote character(s) still attached
|
|
697
|
-
* (`git -C "/tmp/repoB" status` captures `"/tmp/repoB"`, quotes included —
|
|
698
|
-
* not a real path, and not `null` either under the pre-existing
|
|
699
|
-
* extraction) rather than being rejected outright. The same is true of a
|
|
700
|
-
* value containing a command-substitution marker (`$(` or a backtick) —
|
|
701
|
-
* this module never resolves substitutions, so a value carrying one names
|
|
702
|
-
* nothing this module can vouch for as a literal path. A
|
|
703
|
-
* whitespace-containing path needs no separate check here: the
|
|
704
|
-
* whitespace-splitting tokeniser never captures a value containing
|
|
705
|
-
* whitespace as ONE token in the first place — it becomes a different,
|
|
706
|
-
* unrecognised token sequence instead (see the module header's own
|
|
707
|
-
* "quoted directory arguments containing whitespace" note), which for
|
|
708
|
-
* `segmentViewOf` purposes simply fails to produce a recognised own target
|
|
709
|
-
* at all (see `parseCdSegmentTarget` below for the `cd` case).
|
|
710
|
-
*
|
|
711
|
-
* A value ENDING in a bare `$` is ALSO rejected, even though it does not
|
|
712
|
-
* literally contain the two-character `$(` marker: `BOUNDARY_RE` (this
|
|
713
|
-
* function is only ever reached through the primary, non-amp-aware pass —
|
|
714
|
-
* `segmentViewOf` never runs under `AMP_BOUNDARY_RE`) treats a bare `(` as
|
|
715
|
-
* a shell boundary UNCONDITIONALLY (needed for the leading-command case,
|
|
716
|
-
* module header), so `$(` can never survive as a substring WITHIN one
|
|
717
|
-
* token or segment in the first place — the `(` always splits it into its
|
|
718
|
-
* own boundary match first. A `cd` target ending in `$` is the tell-tale
|
|
719
|
-
* leftover of exactly that split (`cd $(pwd)/T && ...` tokenises this
|
|
720
|
-
* function's caller's OWN segment as literal `cd $`, a syntactically
|
|
721
|
-
* valid-LOOKING two-token `cd <path>` with `<path>` being the single
|
|
722
|
-
* character `$` — measured live, not hypothetical). Rejecting it here
|
|
723
|
-
* closes that gap without needing `parseCdSegmentTarget` (which has no
|
|
724
|
-
* visibility into what boundary token follows its own segment) to
|
|
725
|
-
* special-case it itself. The `-C`/`--work-tree`/`--git-dir`/`env -C` side
|
|
726
|
-
* of this module never has this specific gap: a value-capturing git
|
|
727
|
-
* global option additionally requires a SUBCOMMAND token after its value
|
|
728
|
-
* (`peelGitGlobalOptions`'s caller checks `gitOpts.idx >= tokens.length`),
|
|
729
|
-
* so `git -C $(pwd) status` already comes back `isGit: false` (no
|
|
730
|
-
* subcommand token survives the split) before this function is ever
|
|
731
|
-
* consulted for it — this extra check is `cd`-specific, `cd <path>` having
|
|
732
|
-
* no analogous "and then a subcommand" requirement.
|
|
733
|
-
*/
|
|
734
|
-
function isUnattributableTargetValue(value) {
|
|
735
|
-
return (value.includes("'") ||
|
|
736
|
-
value.includes('"') ||
|
|
737
|
-
value.includes("$(") ||
|
|
738
|
-
value.includes("`") ||
|
|
739
|
-
value.endsWith("$"));
|
|
740
|
-
}
|
|
741
|
-
function classifyCdSegment(segmentText) {
|
|
742
|
-
const tokens = tokenizeWithOffsets(segmentText);
|
|
743
|
-
if (tokens.length === 0)
|
|
744
|
-
return { kind: "none" };
|
|
745
|
-
const head = tokens[0].text;
|
|
746
|
-
if (head === "pushd" || head === "popd")
|
|
747
|
-
return { kind: "reset" };
|
|
748
|
-
if (head !== "cd")
|
|
749
|
-
return { kind: "none" };
|
|
750
|
-
if (tokens.length === 1)
|
|
751
|
-
return { kind: "reset" }; // bare `cd`, no argument
|
|
752
|
-
if (tokens.length > 2)
|
|
753
|
-
return { kind: "reset" }; // flagged cd: `cd -P /tmp/x`
|
|
754
|
-
const arg = tokens[1].text;
|
|
755
|
-
if (arg.length === 0 || arg.startsWith("-"))
|
|
756
|
-
return { kind: "reset" }; // `cd -`, `cd -P` alone
|
|
757
|
-
return { kind: "cd", arg };
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* Compute one segment's `ownTarget` / `effectiveTarget` (task `98ad072f`
|
|
761
|
-
* groundwork, `segmentViewOf` / `CommandSegment`) and the cd-basis to
|
|
762
|
-
* carry into the NEXT segment, from: this segment's own canonicalisation
|
|
763
|
-
* result (already computed by the shared walk — `identityTargetDir` /
|
|
764
|
-
* `identityTargetBase` / `isGit`, unmodified here; NOT the OLD aggregate's
|
|
765
|
-
* `targetDir`/`targetBase`, which still includes `--work-tree` — D-017,
|
|
766
|
-
* fix round 2), whether THIS segment is itself a recognised `cd <path>`
|
|
767
|
-
* segment, and the cd-basis carried in from whatever preceded it.
|
|
768
|
-
* Implements `CommandSegment.effectiveTarget`'s doc comment verbatim; see
|
|
769
|
-
* that comment for the consumer-facing statement of these rules.
|
|
770
|
-
*/
|
|
771
|
-
function computeSegmentTarget(segmentText, canon, incomingCdBasis) {
|
|
772
|
-
// Same-invocation `env -C`/`--chdir` base + a RELATIVE own git
|
|
773
|
-
// REPO-RELOCATING target (`-C`/`--git-dir` — orchestrator follow-up
|
|
774
|
-
// after the initial T-002 round, still task `98ad072f` groundwork):
|
|
775
|
-
// `canon.identityTargetBase` is non-null EXACTLY in this mixed case —
|
|
776
|
-
// see `canonicalizeSegment`'s own `identityTargetBase` computation,
|
|
777
|
-
// reused here unmodified, never when there is no wrapping `env -C` on
|
|
778
|
-
// the SAME invocation. `env -C /tmp/base git -C sub status` must NOT
|
|
779
|
-
// hand a future consumer `ownTarget`/`effectiveTarget: "sub"` (raw,
|
|
780
|
-
// relative): a naive consumer resolving a relative value against ITS
|
|
781
|
-
// OWN cwd (this module's documented fallback for the "no known basis"
|
|
782
|
-
// branch below) would land on a CONFIDENTLY WRONG repository whenever
|
|
783
|
-
// the caller's cwd differs from `/tmp/base` — precisely the "relative
|
|
784
|
-
// and `~` target dirs resolve to a confidently wrong repo" class named
|
|
785
|
-
// in the 07-27 review. Composing correctly to `/tmp/base/sub` would ALSO
|
|
786
|
-
// be correct but is deliberately NOT this module's job (real
|
|
787
|
-
// filesystem-shaped path joining — the same reason the cd-basis
|
|
788
|
-
// composition rule below refuses to compose `cd T && git -C sub` into
|
|
789
|
-
// `T/sub`); noted here as a possible LATER precision gain, not built in
|
|
790
|
-
// this slice. A bare git invocation with an `env -C` base and no
|
|
791
|
-
// relative own REPO-RELOCATING target (`env -C /abs git status`) is
|
|
792
|
-
// UNAFFECTED — the ordinary `canon.identityTargetDir` extraction below
|
|
793
|
-
// already folds that in (`gitOpts.relocateTargetDir ?? envTargetDir`),
|
|
794
|
-
// and `canon.identityTargetBase` stays `null` for that shape (no
|
|
795
|
-
// relative own repo-relocating target to be relative TO). A `--work-
|
|
796
|
-
// tree`-only invocation (no `-C`/`--git-dir` on the same call) is NEVER
|
|
797
|
-
// "mixed" here even when wrapped by `env -C`: `relocateTargetDir` is
|
|
798
|
-
// `null` for it, so `identityTargetDir` falls through to `envTargetDir`
|
|
799
|
-
// (or stays `null`) and `identityTargetBase` stays `null` — D-017, this
|
|
800
|
-
// is the fix. A `cd`-shaped segment never reaches this branch: it never
|
|
801
|
-
// matches `GIT_TOKEN_RE`, so `canon.isGit` is always `false` for one.
|
|
802
|
-
if (canon.isGit && canon.identityTargetBase !== null) {
|
|
803
|
-
return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: incomingCdBasis };
|
|
804
|
-
}
|
|
805
|
-
const cdClass = classifyCdSegment(segmentText);
|
|
806
|
-
// D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign): a
|
|
807
|
-
// segment that GENUINELY changes directory to somewhere this module
|
|
808
|
-
// cannot name (bare `cd`, `cd -`, a flagged `cd`, `pushd`, `popd`)
|
|
809
|
-
// RESETS the running basis to `null` for every later segment, rather
|
|
810
|
-
// than the pre-fix behaviour of leaving it unchanged (silently
|
|
811
|
-
// INHERITING whatever preceded it, as though the shell had not moved at
|
|
812
|
-
// all). This segment names nothing itself either. A PRECISION fix, not
|
|
813
|
-
// the security fix: `src/runtime/intercept.ts`'s D-011 additive
|
|
814
|
-
// attribution rule means an inherited target always also demands the
|
|
815
|
-
// cwd context, so an over-cautious reset here can only make a LATER
|
|
816
|
-
// segment fall back to cwd-only evaluation — identical to the shipped
|
|
817
|
-
// baseline for an unattributable form (D-003), never a fail-open.
|
|
818
|
-
if (cdClass.kind === "reset") {
|
|
819
|
-
return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: null };
|
|
820
|
-
}
|
|
821
|
-
const cdArg = cdClass.kind === "cd" ? cdClass.arg : null;
|
|
822
|
-
const rawOwn = cdArg !== null ? cdArg : canon.isGit ? canon.identityTargetDir : null;
|
|
823
|
-
const ownTarget = rawOwn !== null && !isTildeTarget(rawOwn) && !isUnattributableTargetValue(rawOwn)
|
|
824
|
-
? rawOwn
|
|
825
|
-
: null;
|
|
826
|
-
let effectiveTarget;
|
|
827
|
-
if (ownTarget === null) {
|
|
828
|
-
// D-014: a recognised `cd <path>` segment whose OWN value came out
|
|
829
|
-
// unattributable (tilde/quoted/substitution) RESETS rather than
|
|
830
|
-
// inherits — the shell genuinely changed directory to somewhere this
|
|
831
|
-
// module cannot name, so silently keeping an earlier basis in place
|
|
832
|
-
// would be a confidently-wrong carry-forward, exactly the class this
|
|
833
|
-
// whole module exists to avoid. A segment that is NOT cd-shaped at
|
|
834
|
-
// all (`cdArg === null`, `cdClass.kind === "none"`) still inherits —
|
|
835
|
-
// this module has no evidence the shell moved, so it does not guess.
|
|
836
|
-
effectiveTarget = cdArg !== null ? null : incomingCdBasis;
|
|
837
|
-
}
|
|
838
|
-
else if (path.isAbsolute(ownTarget)) {
|
|
839
|
-
effectiveTarget = ownTarget;
|
|
840
|
-
}
|
|
841
|
-
else {
|
|
842
|
-
effectiveTarget = incomingCdBasis === null ? ownTarget : null;
|
|
843
|
-
}
|
|
844
|
-
// Only a recognised `cd <path>` segment updates the outgoing basis
|
|
845
|
-
// (whether or not its OWN value came out attributable — see
|
|
846
|
-
// `CommandSegment.effectiveTarget`'s doc comment for why an
|
|
847
|
-
// unattributable `cd` must still RESET the basis to `null`, not leave a
|
|
848
|
-
// stale earlier one in place). Every other segment — gated or not —
|
|
849
|
-
// never itself changes the shell's directory, so the incoming basis
|
|
850
|
-
// passes through unchanged.
|
|
851
|
-
const outgoingCdBasis = cdArg !== null ? effectiveTarget : incomingCdBasis;
|
|
852
|
-
return { ownTarget, effectiveTarget, outgoingCdBasis };
|
|
853
|
-
}
|
|
854
|
-
/**
|
|
855
|
-
* Given a Bash command string, return its normalised form and the target
|
|
856
|
-
* directory (and base, if relative) of its git invocation. Never throws:
|
|
857
|
-
* any internal failure (there should be none — this is a defensive
|
|
858
|
-
* backstop matching the fail-safe shape used elsewhere in this codebase,
|
|
859
|
-
* e.g. `policyMatchesEvent`'s `try/catch` around `new RegExp`) falls back
|
|
860
|
-
* to the input unchanged.
|
|
861
|
-
*/
|
|
862
|
-
export function normalizeCommand(command) {
|
|
863
|
-
if (typeof command !== "string" || command.length === 0) {
|
|
864
|
-
return {
|
|
865
|
-
normalized: typeof command === "string" ? command : "",
|
|
866
|
-
targetDir: null,
|
|
867
|
-
targetBase: null,
|
|
868
|
-
truncated: false,
|
|
869
|
-
};
|
|
870
|
-
}
|
|
871
|
-
if (command.length > MAX_NORMALIZE_LENGTH) {
|
|
872
|
-
return { normalized: command, targetDir: null, targetBase: null, truncated: true };
|
|
873
|
-
}
|
|
874
|
-
try {
|
|
875
|
-
return normalizeCommandInner(command);
|
|
876
|
-
}
|
|
877
|
-
catch {
|
|
878
|
-
return { normalized: command, targetDir: null, targetBase: null, truncated: false };
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
/**
|
|
882
|
-
* Given a Bash command string, return its per-segment view (task
|
|
883
|
-
* `98ad072f` groundwork, T-002) — see `CommandSegment`'s own doc comment
|
|
884
|
-
* for the field-level composition rules. `null` when the input exceeded
|
|
885
|
-
* `MAX_NORMALIZE_LENGTH` (mirrors `NormalizedCommand.truncated`, but as
|
|
886
|
-
* the ABSENCE of a view rather than a boolean flag alongside one, since
|
|
887
|
-
* there is no meaningful per-segment view to return for an input whose
|
|
888
|
-
* normalisation itself was skipped). `[]` for an empty or non-string
|
|
889
|
-
* command, and as the never-throws fallback (mirrors `normalizeCommand`'s
|
|
890
|
-
* own defensive backstop — see its doc comment above).
|
|
891
|
-
*
|
|
892
|
-
* THIS function's OWN call into `segmentAndCanonicalize` (`collectSegments:
|
|
893
|
-
* true`) is one walk, O(length) — the "no additional pass" claim is scoped
|
|
894
|
-
* to that ONE call, not to a caller's total work. CORRECTED (D-015, fix
|
|
895
|
-
* round, run 2026-08-02-per-repo-gate-scoping-redesign) — the prior wording
|
|
896
|
-
* here read "no additional pass over the command string" without that
|
|
897
|
-
* scoping, which a reader could take as "calling this costs nothing beyond
|
|
898
|
-
* `normalizeCommand`'s own walk." Measured false: `normalizeCommand(cmd)`
|
|
899
|
-
* and `segmentViewOf(cmd)` are two SEPARATE top-level calls, each running
|
|
900
|
-
* its own full `segmentAndCanonicalize` pass over the SAME string — a
|
|
901
|
-
* caller needing both (as `src/cli/policy/intercept.ts` does, for
|
|
902
|
-
* `normalized`/`truncated` and the segment view respectively) pays TWO
|
|
903
|
-
* walks, not one; measured +206% at the `MAX_NORMALIZE_LENGTH` bound
|
|
904
|
-
* (2.73 → 8.36 ms), harmless against the 1000 ms hook budget but not free.
|
|
905
|
-
* As of the same fix round, the one production caller only pays this
|
|
906
|
-
* second walk lazily, when some matching policy actually needs it (see
|
|
907
|
-
* `InterceptOptions.commandSegmentsThunk` in `src/runtime/intercept.ts`) —
|
|
908
|
-
* but that laziness lives in the CALLER, not in this function, which still
|
|
909
|
-
* does exactly one walk every time it runs. The ampersand-aware second pass
|
|
910
|
-
* (`normalizeCommandAmpAware`) NEVER collects a segment view (always calls
|
|
911
|
-
* `segmentAndCanonicalize` with `collectSegments: false`) — see that
|
|
912
|
-
* function's own comment for why a directory extracted under the
|
|
913
|
-
* ampersand-aware alphabet would be unreliable in the first place; the
|
|
914
|
-
* same reasoning applies here, so this module offers no amp-aware variant
|
|
915
|
-
* of this function at all.
|
|
916
|
-
*/
|
|
917
|
-
export function segmentViewOf(command) {
|
|
918
|
-
if (typeof command !== "string" || command.length === 0)
|
|
919
|
-
return [];
|
|
920
|
-
if (command.length > MAX_NORMALIZE_LENGTH)
|
|
921
|
-
return null;
|
|
922
|
-
try {
|
|
923
|
-
return segmentAndCanonicalize(command, BOUNDARY_RE, true).segments ?? [];
|
|
924
|
-
}
|
|
925
|
-
catch {
|
|
926
|
-
return [];
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* Ampersand-aware sibling of `segmentViewOf` (task d03af8f6, review
|
|
931
|
-
* round 3): the same per-segment view, but segmented under
|
|
932
|
-
* `AMP_BOUNDARY_RE` (`BOUNDARY_RE` plus a bare `&`) instead of
|
|
933
|
-
* `BOUNDARY_RE` — closing the same bare-`&` gap `normalizeCommandAmpAware`
|
|
934
|
-
* closes for trigger matching (`echo hi & rm -rf /home/x`), but as a
|
|
935
|
-
* segment VIEW rather than a rejoined string, for
|
|
936
|
-
* `src/runtime/deletion-target-resolve.ts`'s multi-segment scan. Uses the
|
|
937
|
-
* SAME shared walk (`segmentAndCanonicalize`, parameterised on
|
|
938
|
-
* `boundaryRe` since task aabbad63) as every other pass in this module —
|
|
939
|
-
* no new segmentation logic. `CommandSegment.effectiveTarget`'s own
|
|
940
|
-
* composition rules are UNCHANGED by which alphabet drove the split; a
|
|
941
|
-
* caller that only needs `.text` per segment (as the deletion resolver
|
|
942
|
-
* does) is unaffected by the same directory-extraction reliability
|
|
943
|
-
* caveat `normalizeCommandAmpAware`'s own comment names for `targetDir`/
|
|
944
|
-
* `targetBase` — this function returns the FULL `CommandSegment` shape,
|
|
945
|
-
* `effectiveTarget` included, so a future caller relying on THAT field
|
|
946
|
-
* under this alphabet should read `normalizeCommandAmpAware`'s comment
|
|
947
|
-
* first. `null` under the same `MAX_NORMALIZE_LENGTH` condition
|
|
948
|
-
* `segmentViewOf` uses; `[]` for an empty/non-string command or the
|
|
949
|
-
* never-throws fallback.
|
|
950
|
-
*/
|
|
951
|
-
export function segmentViewOfAmpAware(command) {
|
|
952
|
-
if (typeof command !== "string" || command.length === 0)
|
|
953
|
-
return [];
|
|
954
|
-
if (command.length > MAX_NORMALIZE_LENGTH)
|
|
955
|
-
return null;
|
|
956
|
-
try {
|
|
957
|
-
return segmentAndCanonicalize(command, AMP_BOUNDARY_RE, true).segments ?? [];
|
|
958
|
-
}
|
|
959
|
-
catch {
|
|
960
|
-
return [];
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
/**
|
|
964
|
-
* Ampersand-aware SECOND normalisation pass (task aabbad63). Segments the
|
|
965
|
-
* command under `AMP_BOUNDARY_RE` (`BOUNDARY_RE` plus a bare `&`) instead
|
|
966
|
-
* of `BOUNDARY_RE`, then canonicalises exactly like the primary pass
|
|
967
|
-
* (same `canonicalizeSegment`, same wrapper-peeling rules, same
|
|
968
|
-
* `MAX_NORMALIZE_LENGTH` bound). Closes the gap `BOUNDARY_RE`'s own
|
|
969
|
-
* comment names: `A=x&env -C /tmp git status` and `echo hi & nice git
|
|
970
|
-
* status` were ungated because a wrapper can only be peeled after a
|
|
971
|
-
* recognised boundary, and a bare `&` was not one.
|
|
972
|
-
*
|
|
973
|
-
* Return type deliberately has NO `targetDir`/`targetBase` — not merely
|
|
974
|
-
* "always null under this alphabet", genuinely ABSENT from the type, so
|
|
975
|
-
* no consumer can read one by mistake. Reason: a bare `&` is ALSO how
|
|
976
|
-
* bash spells a background job (`git -C /x push &`) and how a redirect
|
|
977
|
-
* merges streams (`git -C /x log 2>&1`, `... &> out`). Under THIS
|
|
978
|
-
* alphabet each of those splits into a spurious extra "segment", which
|
|
979
|
-
* `segmentAndCanonicalize`'s own non-git-segment ambiguity rule (a
|
|
980
|
-
* segment that starts a new command and is not itself a git invocation
|
|
981
|
-
* forces `targetDir` to `null` — see its G1 comment) would turn into a
|
|
982
|
-
* wrong-shaped loss for a case the PRIMARY pass still resolves correctly
|
|
983
|
-
* today. Pinned in `tests/runtime/command-normalize.test.ts`'s "aabbad63
|
|
984
|
-
* groundwork" describe block, which exercises ONLY `normalizeCommand`
|
|
985
|
-
* (the primary pass), never this function — `targetDir`/`targetBase`
|
|
986
|
-
* stay the primary pass's property alone.
|
|
987
|
-
*
|
|
988
|
-
* Only consulted by `policyMatchesEvent`'s third arm
|
|
989
|
-
* (`src/runtime/intercept.ts`), and only when a policy's `bash_match`
|
|
990
|
-
* regex has already missed BOTH the raw command and the primary
|
|
991
|
-
* normalised form — see that module and `src/cli/policy/intercept.ts`
|
|
992
|
-
* for the memoised-thunk threading that keeps this computation off the
|
|
993
|
-
* common (already-matched) path.
|
|
994
|
-
*/
|
|
995
|
-
export function normalizeCommandAmpAware(command) {
|
|
996
|
-
return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, AMP_BOUNDARY_RE, false).normalized);
|
|
997
|
-
}
|
|
998
|
-
/**
|
|
999
|
-
* Shared empty/length/throw guard clauses for a normalisation pass whose
|
|
1000
|
-
* public return shape is exactly `{ normalized, truncated }` — no
|
|
1001
|
-
* `targetDir`/`targetBase` (task cf3dff51 extraction: before this,
|
|
1002
|
-
* `normalizeCommandAmpAware` and `normalizeCommandQuoteAware` carried
|
|
1003
|
-
* byte-identical copies of this wrapper, which `npm run check:duplication`
|
|
1004
|
-
* — a jscpd clone-count fitness function, see that script's own header
|
|
1005
|
-
* comment for the precedent this follows — flagged as new duplication
|
|
1006
|
-
* against the pinned baseline; extracted rather than raising the pin,
|
|
1007
|
-
* per that script's own stated preference). `normalizeCommand` itself is
|
|
1008
|
-
* NOT this shape (its return type also carries `targetDir`/`targetBase`)
|
|
1009
|
-
* and keeps its own, separate guard clauses — deliberately not folded in
|
|
1010
|
-
* here, since unifying THREE different return shapes behind one generic
|
|
1011
|
-
* helper would obscure more than the ~10 duplicated lines it would save.
|
|
1012
|
-
* `compute` is called only once `command` has passed both guards, so it
|
|
1013
|
-
* never needs to re-check emptiness or the length bound itself.
|
|
1014
|
-
*/
|
|
1015
|
-
function normalizeWithGuards(command, compute) {
|
|
1016
|
-
if (typeof command !== "string" || command.length === 0) {
|
|
1017
|
-
return { normalized: typeof command === "string" ? command : "", truncated: false };
|
|
1018
|
-
}
|
|
1019
|
-
if (command.length > MAX_NORMALIZE_LENGTH) {
|
|
1020
|
-
return { normalized: command, truncated: true };
|
|
1021
|
-
}
|
|
1022
|
-
try {
|
|
1023
|
-
return { normalized: compute(command), truncated: false };
|
|
1024
|
-
}
|
|
1025
|
-
catch {
|
|
1026
|
-
return { normalized: command, truncated: false };
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
function normalizeCommandInner(command) {
|
|
1030
|
-
const { normalized, targetDir, targetBase } = segmentAndCanonicalize(command, BOUNDARY_RE, false);
|
|
1031
|
-
return { normalized, targetDir, targetBase, truncated: false };
|
|
1032
|
-
}
|
|
1033
|
-
/**
|
|
1034
|
-
* Quote-aware THIRD normalisation pass (task cf3dff51). Segments the
|
|
1035
|
-
* command under `BOUNDARY_RE`'s own alphabet — same characters as the
|
|
1036
|
-
* primary pass, no widening — but tracks single/double-quote state while
|
|
1037
|
-
* scanning (`findNextBoundaryQuoteAware` below) so a boundary character
|
|
1038
|
-
* sitting INSIDE a quoted assignment value is no longer treated as a real
|
|
1039
|
-
* segment split. Closes the gap the module header's "DELIBERATELY NOT
|
|
1040
|
-
* SUPPORTED" list and `BOUNDARY_RE`'s own comment both name: `VAR='a; b'
|
|
1041
|
-
* git push` (task 13e55484's pinned residual) — `BOUNDARY_RE` used to cut
|
|
1042
|
-
* the segment at the `;`, landing squarely between the two halves of one
|
|
1043
|
-
* quoted value, so neither resulting piece carried a recognisable
|
|
1044
|
-
* invocation and `consumeAssignment`'s own multi-token quote continuation
|
|
1045
|
-
* (built for exactly this shape) never got the chance to run: it operates
|
|
1046
|
-
* AFTER segmentation, on tokens WITHIN one segment, and the quoted value
|
|
1047
|
-
* was never in one segment to begin with. Once quote-aware segmentation
|
|
1048
|
-
* keeps the value whole, `consumeAssignment` needs no changes at all — it
|
|
1049
|
-
* already reconstructs a multi-token quoted value correctly; this pass
|
|
1050
|
-
* only fixes what gets handed to it as ONE segment in the first place.
|
|
1051
|
-
*
|
|
1052
|
-
* BUILT AS A NEW, INDEPENDENT PASS RATHER THAN AN IN-PLACE EDIT OF
|
|
1053
|
-
* `BOUNDARY_RE` / `segmentAndCanonicalize` — this is the module header's
|
|
1054
|
-
* HALT CRITERION, restated here at the point it governs: quote-aware
|
|
1055
|
-
* segmentation can MERGE a segment the primary pass currently splits, and
|
|
1056
|
-
* that merge cuts in BOTH directions — it can turn a previously-missed
|
|
1057
|
-
* bypass into a match (the fix), but it can just as easily turn a
|
|
1058
|
-
* previously-accepted, ALREADY-WIRED over-block into a miss (a
|
|
1059
|
-
* regression). Measured, not hypothetical: `tests/runtime/command-
|
|
1060
|
-
* normalize.test.ts`'s "accepted over-block: a quoted assignment inside
|
|
1061
|
-
* TEXT after a boundary char now matches" pin — `echo "a; VAR='x y' git
|
|
1062
|
-
* push"` — matches via `normalizeCommand` TODAY only because `BOUNDARY_RE`
|
|
1063
|
-
* splits it at the `;`, landing `VAR='x y' git push` at a segment start
|
|
1064
|
-
* where the assignment continuation turns it into a match. An in-place
|
|
1065
|
-
* quote-aware edit of THAT pass would un-split it back into the outer
|
|
1066
|
-
* `echo "..."` segment — not itself a recognised invocation — and flip
|
|
1067
|
-
* that pin to a lost match, in violation of the raw-OR-normalised-OR-amp-
|
|
1068
|
-
* aware-OR-(this) never-lose-a-match discipline every other pass in this
|
|
1069
|
-
* module already honours. Building this as a SEPARATE pass instead means
|
|
1070
|
-
* `normalizeCommand`'s own segmentation, and every test resting on it —
|
|
1071
|
-
* including that exact one — is untouched by this task; see this file's
|
|
1072
|
-
* own test suite for the differential proof (every case the first three
|
|
1073
|
-
* arms already matched still matches, by construction, because none of
|
|
1074
|
-
* those three arms' code changed at all).
|
|
1075
|
-
*
|
|
1076
|
-
* WIRED to `policyMatchesEvent` (`src/runtime/intercept.ts`) as its
|
|
1077
|
-
* FOURTH arm — same shape as `normalizeCommandAmpAware`'s own third-arm
|
|
1078
|
-
* wiring (task aabbad63): consulted only after the raw command,
|
|
1079
|
-
* `normalizedCommand`, and the amp-aware pass have all missed, via a
|
|
1080
|
-
* memoised thunk (`InterceptOptions.quoteNormalizedCommandThunk`,
|
|
1081
|
-
* `src/cli/policy/intercept.ts`'s `quoteNormalizedCommandThunk`) so the
|
|
1082
|
-
* segmentation cost is paid at most once per event, on the common
|
|
1083
|
-
* (already-matched) path it costs nothing at all. Wiring landed in a
|
|
1084
|
-
* SECOND round of this task, after the orchestrator extended the first
|
|
1085
|
-
* round's file scope specifically for this one wiring point — the first
|
|
1086
|
-
* round shipped this function fully built and tested but genuinely
|
|
1087
|
-
* unwired (an inert security fix, called out explicitly as the wrong
|
|
1088
|
-
* shipped state in that round's own report) — `src/cli/dry-run.ts`'s own,
|
|
1089
|
-
* independently-copied matcher was deliberately NOT extended in the same
|
|
1090
|
-
* round (out of the extended scope too) and stayed a named residual until
|
|
1091
|
-
* task f561e44c closed it by adding the same fourth OR-arm there too — see
|
|
1092
|
-
* that file's own comment.
|
|
1093
|
-
*
|
|
1094
|
-
* THIS PASS'S OWN OVER-BLOCK SURFACE is measured and named in the test
|
|
1095
|
-
* file rather than assumed — and the measurement CORRECTS an earlier,
|
|
1096
|
-
* pre-measurement guess that used to sit in this comment: it does NOT
|
|
1097
|
-
* reproduce the `echo "a; VAR='x y' git push"` accepted over-block named
|
|
1098
|
-
* above. Quote-aware merging keeps that whole `echo "..."` segment
|
|
1099
|
-
* together, starting with the unrecognised head token `echo`, so this
|
|
1100
|
-
* function's output for it is byte-identical to the raw input
|
|
1101
|
-
* (`normalizeCommand`'s OWN, separate, unchanged-by-this-task match for
|
|
1102
|
-
* that same string is unaffected either way). Measured against a broader
|
|
1103
|
-
* adversarial probe, both at this function's own level and again at
|
|
1104
|
-
* `policyMatchesEvent`'s wired level (a bash-shim-verified corpus of
|
|
1105
|
-
* chained/prefixed/subshell-wrapped genuine invocations plus candidate
|
|
1106
|
-
* over-block shapes — entirely quoted `echo`/`printf` arguments that only
|
|
1107
|
-
* PRINT the spelling): every additional match this pass adds beyond raw /
|
|
1108
|
-
* `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive (a
|
|
1109
|
-
* real invocation bash would actually execute); no NEW over-block class
|
|
1110
|
-
* distinct from `consumeAssignment`'s own, already-reviewed precision
|
|
1111
|
-
* limits was found. This function shares `canonicalizeSegment` /
|
|
1112
|
-
* `consumeAssignment` completely unchanged with the other passes — only
|
|
1113
|
-
* the segmentation boundary decision differs — which is also the
|
|
1114
|
-
* structural reason a longer (merged) segment can only REVEAL a match
|
|
1115
|
-
* those functions would already accept from a shorter view, never
|
|
1116
|
-
* fabricate a new kind of one; see the module header's cf3dff51 UPDATE
|
|
1117
|
-
* paragraph for the fuller statement of this reasoning and its
|
|
1118
|
-
* "not a formal proof" caveat.
|
|
1119
|
-
*/
|
|
1120
|
-
export function normalizeCommandQuoteAware(command) {
|
|
1121
|
-
return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, BOUNDARY_RE, false, true).normalized);
|
|
1122
|
-
}
|
|
1123
|
-
/**
|
|
1124
|
-
* The shared segmentation + canonicalisation engine behind BOTH
|
|
1125
|
-
* normalisation passes (task aabbad63). Parameterised on the boundary
|
|
1126
|
-
* alphabet (`boundaryRe`) instead of closing over a single module-level
|
|
1127
|
-
* regex, so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
|
|
1128
|
-
* second pass (`AMP_BOUNDARY_RE`) never share regex scan state: each
|
|
1129
|
-
* call is handed its OWN regex object, and `findNextBoundary` resets
|
|
1130
|
-
* that object's own `lastIndex` before every use, so the two passes
|
|
1131
|
-
* (even if a future change nested one inside the other, which nothing
|
|
1132
|
-
* here does today) can never observe or clobber each other's position.
|
|
1133
|
-
* `targetDir`/`targetBase` ARE still computed here regardless of which
|
|
1134
|
-
* alphabet drove the segmentation — `normalizeCommandAmpAware` below
|
|
1135
|
-
* simply never returns them to its own caller; see that function's
|
|
1136
|
-
* comment for why exposing them would be wrong under the amp alphabet
|
|
1137
|
-
* specifically. Fix round 1, finding F10: under the amp alphabet
|
|
1138
|
-
* specifically, ALL of this function's target bookkeeping
|
|
1139
|
-
* (`explicitTargets`, `explicitTargetBase`, `bareGitSegmentCount`,
|
|
1140
|
-
* `leadingCd`'s full `parseBashPrefix` pass, and the whole G1 ambiguity
|
|
1141
|
-
* resolution below) is knowingly computed and then discarded by
|
|
1142
|
-
* `normalizeCommandAmpAware` ITSELF, which returns only
|
|
1143
|
-
* `{ normalized, truncated }` — its own caller never sees these fields at
|
|
1144
|
-
* all. This is the containment design's cost, not an oversight, so a
|
|
1145
|
-
* future reader should not "fix" it by exposing these fields to a
|
|
1146
|
-
* hypothetical amp-alphabet consumer.
|
|
1147
|
-
*
|
|
1148
|
-
* `collectSegments` (task `98ad072f` groundwork, T-002): when `true`, ALSO
|
|
1149
|
-
* builds the per-segment view `segmentViewOf` returns (`CommandSegment[]`)
|
|
1150
|
-
* inside this SAME walk — no SECOND walk INSIDE this one function call,
|
|
1151
|
-
* just a bounded amount of extra work per segment already being visited
|
|
1152
|
-
* (see `computeSegmentTarget`). Scoped claim only (D-015 correction, fix
|
|
1153
|
-
* round, run 2026-08-02-per-repo-gate-scoping-redesign): this says nothing
|
|
1154
|
-
* about a CALLER that invokes this function twice with different
|
|
1155
|
-
* `collectSegments` values on the same string — see `segmentViewOf`'s own
|
|
1156
|
-
* doc comment, corrected in the same fix round, for the measured cost of
|
|
1157
|
-
* exactly that. `normalizeCommandInner` and
|
|
1158
|
-
* `normalizeCommandAmpAware` both pass `false`: the OLD aggregate never
|
|
1159
|
-
* needed this, and the amp-aware pass deliberately never exposes a segment
|
|
1160
|
-
* view at all (see that function's own comment). `segments` is `null` on
|
|
1161
|
-
* the returned object when `collectSegments` is `false`, an array
|
|
1162
|
-
* (possibly empty) otherwise.
|
|
1163
|
-
*
|
|
1164
|
-
* `quoteAware` (task cf3dff51, default `false` — every PRE-EXISTING call
|
|
1165
|
-
* site is unchanged, positionally omitting this parameter): selects
|
|
1166
|
-
* `findNextBoundaryQuoteAware` instead of `findNextBoundary` as the one
|
|
1167
|
-
* thing this walk asks to find the next segment split. Everything else in
|
|
1168
|
-
* this function — `canonicalizeSegment`, the target-directory bookkeeping,
|
|
1169
|
-
* the `collectSegments` per-segment view — is IDENTICAL either way; only
|
|
1170
|
-
* WHERE a segment ends changes. Only `normalizeCommandQuoteAware` passes
|
|
1171
|
-
* `true`; see that function's own comment for why this had to be a new
|
|
1172
|
-
* parameter on the SHARED walk rather than a wholly separate function
|
|
1173
|
-
* (reuse, same reasoning `AMP_BOUNDARY_RE`'s parameterisation on
|
|
1174
|
-
* `boundaryRe` already established) and for the HALT CRITERION that ruled
|
|
1175
|
-
* out editing `findNextBoundary` itself in place instead.
|
|
1176
|
-
*/
|
|
1177
|
-
function segmentAndCanonicalize(command, boundaryRe, collectSegments, quoteAware = false) {
|
|
1178
|
-
const parts = [];
|
|
1179
|
-
const explicitTargets = new Set();
|
|
1180
|
-
let explicitTargetBase = null;
|
|
1181
|
-
let sawExplicitBase = false;
|
|
1182
|
-
let bareGitSegmentCount = 0;
|
|
1183
|
-
let i = 0;
|
|
1184
|
-
const n = command.length;
|
|
1185
|
-
// task `98ad072f` groundwork (T-002): the per-segment view's own,
|
|
1186
|
-
// independent bookkeeping — a running cd-basis (see
|
|
1187
|
-
// `computeSegmentTarget`) and the accumulated segments themselves.
|
|
1188
|
-
// Entirely separate from `explicitTargets` / `bareGitSegmentCount` /
|
|
1189
|
-
// `hasAmbiguousNonGitSegment` above, which stay exactly as they were:
|
|
1190
|
-
// this never reads or writes them, and they never read or write this.
|
|
1191
|
-
const segments = collectSegments ? [] : null;
|
|
1192
|
-
let cdBasis = null;
|
|
1193
|
-
// A leading `cd <dir> &&|;` prefix, parsed ONCE up front (G1 fix,
|
|
1194
|
-
// review round 2, 2026-07-27) instead of the two separate calls the
|
|
1195
|
-
// size===1 and size===0 branches below each used to make on their own.
|
|
1196
|
-
// Also needed here now to exempt the recognised leading-cd segment
|
|
1197
|
-
// itself from the new non-git-command ambiguity check just below: that
|
|
1198
|
-
// segment is not "some other command sharing the chain" — it IS the
|
|
1199
|
-
// directory every later bare command in the chain actually runs
|
|
1200
|
-
// inside.
|
|
1201
|
-
const leadingCd = parseBashPrefix(command).cdTarget;
|
|
1202
|
-
// Boundary token immediately preceding the segment currently being
|
|
1203
|
-
// examined (`null` for the very first segment). Tracked so the G1
|
|
1204
|
-
// check below can tell a real "new command" boundary (`&&`/`;`/`\n`/
|
|
1205
|
-
// `(`) apart from a bare `|`, which keeps running in the SAME
|
|
1206
|
-
// directory as whatever precedes it.
|
|
1207
|
-
let precedingBoundaryToken = null;
|
|
1208
|
-
let hasAmbiguousNonGitSegment = false;
|
|
1209
|
-
// Walk the command as alternating (segment, boundary) pairs. Each
|
|
1210
|
-
// segment is handed to `canonicalizeSegment`, which rewrites ONLY its
|
|
1211
|
-
// head (wrapper prefixes + a git invocation's global options, if any
|
|
1212
|
-
// are found) and leaves the remainder of the segment — and every
|
|
1213
|
-
// boundary token — untouched. Bounded: `i` strictly increases by at
|
|
1214
|
-
// least one boundary token's length each iteration a boundary is
|
|
1215
|
-
// found, and the loop stops once none remain.
|
|
1216
|
-
for (;;) {
|
|
1217
|
-
const boundary = quoteAware
|
|
1218
|
-
? findNextBoundaryQuoteAware(command, i, boundaryRe)
|
|
1219
|
-
: findNextBoundary(command, i, boundaryRe);
|
|
1220
|
-
const segEnd = boundary ? boundary.start : n;
|
|
1221
|
-
const segmentText = command.slice(i, segEnd);
|
|
1222
|
-
const result = canonicalizeSegment(segmentText);
|
|
1223
|
-
parts.push(result.text);
|
|
1224
|
-
if (segments !== null) {
|
|
1225
|
-
// task `98ad072f` groundwork (T-002): O(1) extra work per segment
|
|
1226
|
-
// already being visited — see `segmentAndCanonicalize`'s own doc
|
|
1227
|
-
// comment for why this is not a second pass over `command`.
|
|
1228
|
-
//
|
|
1229
|
-
// D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign),
|
|
1230
|
-
// two bash-truthfulness corrections applied around the call, not
|
|
1231
|
-
// inside `computeSegmentTarget` itself (both are about WHICH basis
|
|
1232
|
-
// crosses a BOUNDARY, not about one segment's own composition):
|
|
1233
|
-
//
|
|
1234
|
-
// - a running cd-basis never crosses a bare `|` — each side of a
|
|
1235
|
-
// pipeline is bash's own subshell, so a `cd` on one side cannot
|
|
1236
|
-
// affect the other (measured bypass: `cd <forged> | git push`
|
|
1237
|
-
// really runs `git push` at the real cwd, never `<forged>`).
|
|
1238
|
-
// The segment immediately after a `|` is handed `null` as its
|
|
1239
|
-
// incoming basis, as though nothing preceded it.
|
|
1240
|
-
// - the OUTGOING basis resets to `null` whenever THIS segment's
|
|
1241
|
-
// own text contains a `)` (a subshell close riding along after
|
|
1242
|
-
// `BOUNDARY_RE`'s bare `(` boundary — this alphabet has no `)`
|
|
1243
|
-
// boundary of its own, see its header comment — so a subshell's
|
|
1244
|
-
// `cd` genuinely stops applying once the subshell exits: `(cd
|
|
1245
|
-
// <forged> ; echo hi) && git push` must not let `<forged>` reach
|
|
1246
|
-
// `git push`). THIS segment's own `effectiveTarget` is computed
|
|
1247
|
-
// from the UNCHANGED incoming basis first — a segment ending in
|
|
1248
|
-
// `)` (e.g. the `git status)` tail of `(cd A && git status)`)
|
|
1249
|
-
// still correctly attributes to A for itself, exactly as it
|
|
1250
|
-
// should while still inside the subshell; only what carries
|
|
1251
|
-
// FORWARD to the next segment is affected. A simple substring
|
|
1252
|
-
// check, not paren-depth tracking: conservative, precision-only
|
|
1253
|
-
// under D-011 (see `computeSegmentTarget`'s own D-014 note) — an
|
|
1254
|
-
// over-eager reset here can only make a later segment fall back
|
|
1255
|
-
// to cwd-only, never a fail-open.
|
|
1256
|
-
const incomingBasis = precedingBoundaryToken === "|" ? null : cdBasis;
|
|
1257
|
-
const seg = computeSegmentTarget(segmentText, result, incomingBasis);
|
|
1258
|
-
segments.push({
|
|
1259
|
-
text: result.text,
|
|
1260
|
-
ownTarget: seg.ownTarget,
|
|
1261
|
-
effectiveTarget: seg.effectiveTarget,
|
|
1262
|
-
});
|
|
1263
|
-
cdBasis = segmentText.includes(")") ? null : seg.outgoingCdBasis;
|
|
1264
|
-
}
|
|
1265
|
-
if (result.isGit) {
|
|
1266
|
-
if (result.targetDir === null) {
|
|
1267
|
-
bareGitSegmentCount += 1;
|
|
1268
|
-
}
|
|
1269
|
-
else {
|
|
1270
|
-
explicitTargets.add(result.targetDir);
|
|
1271
|
-
if (!sawExplicitBase && result.targetBase !== null) {
|
|
1272
|
-
explicitTargetBase = result.targetBase;
|
|
1273
|
-
sawExplicitBase = true;
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
else if (precedingBoundaryToken !== "|" &&
|
|
1278
|
-
!(precedingBoundaryToken === null && leadingCd !== null)) {
|
|
1279
|
-
// G1 fix (HIGH, review round 2, 2026-07-27): a segment that STARTS
|
|
1280
|
-
// a new command (follows `&&`/`;`/`\n`/`(` — or is the very first
|
|
1281
|
-
// segment in the command — never a bare `|`, which keeps running
|
|
1282
|
-
// in the SAME directory as whatever it reads from) and is NOT
|
|
1283
|
-
// itself a git invocation is a genuinely DIFFERENT command that
|
|
1284
|
-
// does not necessarily run wherever some OTHER git invocation
|
|
1285
|
-
// elsewhere in the chain explicitly pointed. Measured case: `git
|
|
1286
|
-
// -C <B> rev-parse HEAD && gh pr merge` — `-C` scopes only the ONE
|
|
1287
|
-
// git call it decorates, so `gh pr merge` actually runs at the
|
|
1288
|
-
// real (unaffected) cwd; resolving ${REPO}/${BRANCH} from B for it
|
|
1289
|
-
// let a fact recorded against a decoy repo satisfy
|
|
1290
|
-
// `review-before-merge-bash` / `review-subagent-before-pr-create-
|
|
1291
|
-
// bash` for a merge/PR that never touched B. The one exemption is
|
|
1292
|
-
// the recognised LEADING `cd` prefix itself: unlike `-C`, `cd`
|
|
1293
|
-
// genuinely persists for the rest of the chain, so `cd <B> && git
|
|
1294
|
-
// status` (or any other command after it) really does run in B —
|
|
1295
|
-
// that stays the untouched `explicitTargets.size === 0` branch
|
|
1296
|
-
// below, not this one.
|
|
1297
|
-
hasAmbiguousNonGitSegment = true;
|
|
1298
|
-
}
|
|
1299
|
-
if (!boundary)
|
|
1300
|
-
break;
|
|
1301
|
-
parts.push(boundary.token);
|
|
1302
|
-
i = boundary.start + boundary.token.length;
|
|
1303
|
-
precedingBoundaryToken = boundary.token;
|
|
1304
|
-
}
|
|
1305
|
-
let targetDir;
|
|
1306
|
-
let targetBase = null;
|
|
1307
|
-
if (explicitTargets.size === 1 &&
|
|
1308
|
-
bareGitSegmentCount === 0 &&
|
|
1309
|
-
!hasAmbiguousNonGitSegment) {
|
|
1310
|
-
// Every git invocation the command names agrees on ONE repository —
|
|
1311
|
-
// safe to use it even when there are several (e.g. two `git -C <same
|
|
1312
|
-
// dir>` calls chained together) — AND no OTHER, non-git command
|
|
1313
|
-
// shares the chain that this single target does not actually apply
|
|
1314
|
-
// to (G1 fix, review round 2).
|
|
1315
|
-
targetDir = [...explicitTargets][0];
|
|
1316
|
-
targetBase = explicitTargetBase;
|
|
1317
|
-
if (targetBase === null && !path.isAbsolute(targetDir)) {
|
|
1318
|
-
// F5 fix: a RELATIVE git-level target with no more specific base
|
|
1319
|
-
// (no wrapping `env -C` on the SAME invocation) is relative to
|
|
1320
|
-
// wherever the shell's cwd was at that point in the chain, which a
|
|
1321
|
-
// LEADING `cd <dir> &&|;` prefix moves for every later command.
|
|
1322
|
-
// Only the leading form is consulted — this module does not
|
|
1323
|
-
// simulate the chain deeply enough to attribute a NON-leading `cd`
|
|
1324
|
-
// to a specific later invocation, same ceiling as the leading-only
|
|
1325
|
-
// fallback immediately below.
|
|
1326
|
-
if (leadingCd !== null && !isTildeTarget(leadingCd))
|
|
1327
|
-
targetBase = leadingCd;
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
else if (explicitTargets.size === 0) {
|
|
1331
|
-
// No git invocation named an explicit target of its own (whether
|
|
1332
|
-
// there were zero git invocations, or one or more entirely bare
|
|
1333
|
-
// ones). A leading `cd <dir> &&|;` prefix, if present, names the
|
|
1334
|
-
// target DIRECTLY here — every bare invocation in the chain runs
|
|
1335
|
-
// inside it, so this is not a base for something else, it IS the
|
|
1336
|
-
// answer — delegated to the existing leading-prefix parser rather
|
|
1337
|
-
// than reimplementing its quote-handling. Unaffected by the G1
|
|
1338
|
-
// ambiguity flag above: a non-git command after a leading `cd` is
|
|
1339
|
-
// exactly the case that flag exempts, and a non-git command with NO
|
|
1340
|
-
// git invocation anywhere in the chain never disagrees with
|
|
1341
|
-
// anything, since there is no explicit git target to disagree with
|
|
1342
|
-
// in the first place.
|
|
1343
|
-
targetDir = leadingCd !== null && !isTildeTarget(leadingCd) ? leadingCd : null;
|
|
1344
|
-
}
|
|
1345
|
-
else {
|
|
1346
|
-
// Ambiguous: either more than one git invocation names a DIFFERENT
|
|
1347
|
-
// repository, at least one invocation names a repo explicitly while
|
|
1348
|
-
// another runs bare (which may be a THIRD repo — whatever the
|
|
1349
|
-
// shell's cwd happens to be at that point), or (G1 fix, review round
|
|
1350
|
-
// 2) a non-git, non-leading-cd command elsewhere in the chain that
|
|
1351
|
-
// the single explicit git target does not actually apply to. None of
|
|
1352
|
-
// these has one unambiguous answer, so — per the "when in doubt,
|
|
1353
|
-
// fall back to cwd" rule this whole run was built on — report no
|
|
1354
|
-
// target at all rather than silently picking one invocation's answer
|
|
1355
|
-
// for the whole command (F2 fix; see the module header for the
|
|
1356
|
-
// push-gate fail-open this closes).
|
|
1357
|
-
targetDir = null;
|
|
1358
|
-
}
|
|
1359
|
-
return { normalized: parts.join(""), targetDir, targetBase, segments };
|
|
1360
|
-
}
|
|
1361
|
-
/**
|
|
1362
|
-
* Find the earliest shell boundary token at or after `from`, under the
|
|
1363
|
-
* CALLER-SUPPLIED boundary alphabet. Takes `boundaryRe` as an explicit
|
|
1364
|
-
* parameter rather than closing over a single module-level regex (task
|
|
1365
|
-
* aabbad63) so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
|
|
1366
|
-
* second pass (`AMP_BOUNDARY_RE`) each carry their own `lastIndex` state.
|
|
1367
|
-
*
|
|
1368
|
-
* Fix round 1, finding F8 (corrects the prior wording here): resetting
|
|
1369
|
-
* `lastIndex = from` on every call is NOT what stops the two passes from
|
|
1370
|
-
* clobbering each other's scan position — they are two DISTINCT regex
|
|
1371
|
-
* objects, so there is nothing shared for either to clobber, and
|
|
1372
|
-
* `segmentAndCanonicalize`'s own loop always calls this with `from`
|
|
1373
|
-
* already equal to where the previous match's `lastIndex` would have
|
|
1374
|
-
* landed anyway. That is why removing this reset measured 0 output
|
|
1375
|
-
* differences across the corpus and left the suite green (see
|
|
1376
|
-
* `tests/runtime/command-normalize.test.ts`'s F8 note — the mutation was
|
|
1377
|
-
* applied and reverted to confirm this, not merely reasoned about). The
|
|
1378
|
-
* line is still a legitimate guard, just for a DIFFERENT reason:
|
|
1379
|
-
* `BOUNDARY_RE` and `AMP_BOUNDARY_RE` are module-level `/g` objects
|
|
1380
|
-
* shared by every call in the process, so their `lastIndex` outlives any
|
|
1381
|
-
* single scan. If `canonicalizeSegment` THROWS partway through a scan,
|
|
1382
|
-
* the top-level `catch` in `normalizeCommand` /
|
|
1383
|
-
* `normalizeCommandAmpAware` returns a fallback but leaves that shared
|
|
1384
|
-
* object's `lastIndex` wherever the last successful match landed — and
|
|
1385
|
-
* the NEXT call, on a completely different command, would then start
|
|
1386
|
-
* scanning from that stale offset. This reset re-anchors it.
|
|
1387
|
-
*
|
|
1388
|
-
* DO NOT DELETE THIS LINE ON THE STRENGTH OF ITS MUTATION-INERTNESS.
|
|
1389
|
-
* Measured (fix round 2): with the reset removed and a throw forced into
|
|
1390
|
-
* `canonicalizeSegment`, the FOLLOWING call to
|
|
1391
|
-
* `normalizeCommandAmpAware("a | env git push origin master")` returned
|
|
1392
|
-
* the un-normalised string, and `preflight-before-push` went from MATCH
|
|
1393
|
-
* to NO-MATCH. That is a gate-losing fail-open, not a stylistic nit.
|
|
1394
|
-
* The honest caveat, stated rather than implied: no throw is KNOWN to be
|
|
1395
|
-
* reachable from real input — the leak above was demonstrated with a
|
|
1396
|
-
* forced throw, which is why the line measures inert. The module's own
|
|
1397
|
-
* top-level `catch` exists because a throw is treated as possible, and
|
|
1398
|
-
* this reset is the other half of that same posture.
|
|
1399
|
-
*/
|
|
1400
|
-
function findNextBoundary(s, from, boundaryRe) {
|
|
1401
|
-
boundaryRe.lastIndex = from;
|
|
1402
|
-
const m = boundaryRe.exec(s);
|
|
1403
|
-
return m === null ? null : { start: m.index, token: m[0] };
|
|
1404
|
-
}
|
|
1405
|
-
/**
|
|
1406
|
-
* Quote-aware sibling of `findNextBoundary` (task cf3dff51): the earliest
|
|
1407
|
-
* boundary token at or after `from` that sits OUTSIDE any open single- or
|
|
1408
|
-
* double-quoted run, under the SAME `boundaryRe` alphabet the caller
|
|
1409
|
-
* already scans with — no new boundary characters, only quote-tracking
|
|
1410
|
-
* around the existing ones. `null` when no such boundary exists (either
|
|
1411
|
-
* none remain at all, or every remaining candidate sits inside quoting
|
|
1412
|
-
* that never closes before the string ends — the same conservative "no
|
|
1413
|
-
* further split" fallback `consumeAssignment`'s own unterminated-quote
|
|
1414
|
-
* case already uses, restated here for whole-string segmentation instead
|
|
1415
|
-
* of one token).
|
|
1416
|
-
*
|
|
1417
|
-
* TWO-PHASE, NOT a per-character regex probe (task cf3dff51, latency fix
|
|
1418
|
-
* round — this replaces this function's first shipped implementation,
|
|
1419
|
-
* which called a sticky-regex `.exec()` at EVERY character position;
|
|
1420
|
-
* measured ~2x `normalizeCommand`'s own cost on a large, quote-free
|
|
1421
|
-
* command, because a JS-to-native regex-engine round trip at every single
|
|
1422
|
-
* character is far more expensive than the SAME regex engine's own
|
|
1423
|
-
* internal forward scan across many characters in one call — exactly what
|
|
1424
|
-
* `findNextBoundary` already does for the primary, non-quote-aware pass).
|
|
1425
|
-
* This version instead COMPOSES `findNextBoundary` (unchanged, still the
|
|
1426
|
-
* quote-UNAWARE fast forward scanner) with `QuoteScanner` (below): repeat
|
|
1427
|
-
* — find the next quote-unaware CANDIDATE boundary via `findNextBoundary`
|
|
1428
|
-
* (one native regex scan, however far away the candidate is), then walk
|
|
1429
|
-
* ONLY the span between the previous position and that candidate,
|
|
1430
|
-
* character-by-character, through `QuoteScanner` (below) to learn whether
|
|
1431
|
-
* a quote is open at the candidate's position; if not, the candidate IS
|
|
1432
|
-
* the real boundary — return it; if so, the candidate is inside a quote
|
|
1433
|
-
* (exactly the `VAR='a; b' git push` shape this pass exists to see
|
|
1434
|
-
* through), step through the candidate token's own characters too (never
|
|
1435
|
-
* quote-relevant — `BOUNDARY_RE`'s alphabet contains no `'`/`"`/`\` — so
|
|
1436
|
-
* this is a correctness no-op, only here so the running quote/escape
|
|
1437
|
-
* state stays byte-for-byte identical to a full character-by-character
|
|
1438
|
-
* walk) and continue the search from just past it. For a typical command
|
|
1439
|
-
* (few boundary characters, most of them outside any quote) this pays
|
|
1440
|
-
* for ONE `findNextBoundary` native scan per REAL segment plus one plain
|
|
1441
|
-
* (non-regex) character walk of the same total length `findNextBoundary`
|
|
1442
|
-
* would have scanned internally anyway — no longer one regex call per
|
|
1443
|
-
* character. `null` when no such boundary exists (either none remain at
|
|
1444
|
-
* all — `findNextBoundary` itself returns `null` — or every remaining
|
|
1445
|
-
* candidate sits inside quoting that never closes before the string ends,
|
|
1446
|
-
* the same conservative "no further split" fallback `consumeAssignment`'s
|
|
1447
|
-
* own unterminated-quote case already uses, restated here for whole-
|
|
1448
|
-
* string segmentation instead of one token).
|
|
1449
|
-
*
|
|
1450
|
-
* `QuoteScanner` (below) is the SAME quote/escape state machine
|
|
1451
|
-
* `consumeAssignment`'s own per-token loop uses (three states — `""`,
|
|
1452
|
-
* `'`, `"` — same POSIX approximation: outside quotes `\` escapes the
|
|
1453
|
-
* next char, `'...'` closes only at an UNESCAPED-by-construction `'`
|
|
1454
|
-
* (single quotes never honour backslash escapes at all), `"..."` closes
|
|
1455
|
-
* at an unescaped `"`). Extracted into a shared class (task cf3dff51 fix
|
|
1456
|
-
* round: `npm run check:duplication`'s jscpd scan flagged the two
|
|
1457
|
-
* independently-written copies of this loop as new duplication against
|
|
1458
|
-
* the pinned baseline; see that script's own header comment for why
|
|
1459
|
-
* extraction, not raising the pin, is the stated preference) rather than
|
|
1460
|
-
* duplicated a second time. Generalised from per-token to whole-string
|
|
1461
|
-
* scope by THIS function, not by the scanner itself: `consumeAssignment`
|
|
1462
|
-
* only ever sees one already-whitespace-tokenised segment at a time; this
|
|
1463
|
-
* function walks the RAW command text directly, whitespace included, so
|
|
1464
|
-
* it can tell whether a `;`/`|`/`&&`/`(`/`\n` character sits inside a
|
|
1465
|
-
* quote that a later whitespace-delimited token will re-enter (exactly
|
|
1466
|
-
* the shape `VAR='a; b' git push` needs: the space after `;` is itself
|
|
1467
|
-
* INSIDE the quoted run, not a token boundary this function cares about
|
|
1468
|
-
* at all — only the eventual UNQUOTED position matters).
|
|
1469
|
-
*
|
|
1470
|
-
* Reuses the MODULE-LEVEL `BOUNDARY_QUOTE_SCANNER` instance (`.reset()`
|
|
1471
|
-
* at the top of each call — see that constant's and `reset()`'s own
|
|
1472
|
-
* comments) instead of allocating a fresh `QuoteScanner` per call, and
|
|
1473
|
-
* calls `findNextBoundary` with the CALLER-SUPPLIED `boundaryRe` object
|
|
1474
|
-
* directly rather than a sticky clone — `findNextBoundary` already resets
|
|
1475
|
-
* `lastIndex` itself on every call (its own comment documents exactly
|
|
1476
|
-
* this discipline for the primary pass sharing the SAME `BOUNDARY_RE`
|
|
1477
|
-
* object), so composing it here needs no extra care. Called once per
|
|
1478
|
-
* segment (not once per character) by `segmentAndCanonicalize`'s loop;
|
|
1479
|
-
* the character walk itself still visits each character in the
|
|
1480
|
-
* `[from, end-of-match-or-string)` span exactly once IN TOTAL across all
|
|
1481
|
-
* calls for one command (split across the phase-1 "verify" loop and the
|
|
1482
|
-
* phase-2 "step through the false candidate" loop, never both for the
|
|
1483
|
-
* same character), keeping the whole walk O(length), same complexity
|
|
1484
|
-
* class `findNextBoundary`'s own comment establishes for the primary
|
|
1485
|
-
* pass — this rewrite changes the CONSTANT factor, not the asymptotic
|
|
1486
|
-
* class.
|
|
1487
|
-
*/
|
|
1488
|
-
function findNextBoundaryQuoteAware(s, from, boundaryRe) {
|
|
1489
|
-
BOUNDARY_QUOTE_SCANNER.reset();
|
|
1490
|
-
let pos = from;
|
|
1491
|
-
const n = s.length;
|
|
1492
|
-
for (;;) {
|
|
1493
|
-
const candidate = findNextBoundary(s, pos, boundaryRe);
|
|
1494
|
-
if (candidate === null)
|
|
1495
|
-
return null;
|
|
1496
|
-
// Phase 1: verify quote-safety by walking ONLY the span strictly
|
|
1497
|
-
// BEFORE the candidate — mirrors the original per-character scan,
|
|
1498
|
-
// which never steps through the boundary token's own characters
|
|
1499
|
-
// before deciding whether to return it.
|
|
1500
|
-
for (let i = pos; i < candidate.start; i++) {
|
|
1501
|
-
BOUNDARY_QUOTE_SCANNER.step(s[i]);
|
|
1502
|
-
}
|
|
1503
|
-
if (BOUNDARY_QUOTE_SCANNER.state === "") {
|
|
1504
|
-
return candidate;
|
|
1505
|
-
}
|
|
1506
|
-
// Phase 2: the candidate sits inside an open quote — not a real
|
|
1507
|
-
// boundary. Step through ITS OWN characters too (a correctness
|
|
1508
|
-
// no-op, see this function's own comment) so the running state stays
|
|
1509
|
-
// exactly what a full character-by-character walk would have
|
|
1510
|
-
// produced, then resume the search just past it.
|
|
1511
|
-
const tokenEnd = candidate.start + candidate.token.length;
|
|
1512
|
-
for (let i = candidate.start; i < tokenEnd; i++) {
|
|
1513
|
-
BOUNDARY_QUOTE_SCANNER.step(s[i]);
|
|
1514
|
-
}
|
|
1515
|
-
if (tokenEnd >= n)
|
|
1516
|
-
return null;
|
|
1517
|
-
pos = tokenEnd;
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
/**
|
|
1521
|
-
* Mutable single-/double-quote + backslash-escape state machine (task
|
|
1522
|
-
* cf3dff51 extraction — see `findNextBoundaryQuoteAware`'s own comment for
|
|
1523
|
-
* why this was pulled out of two independently-written copies rather than
|
|
1524
|
-
* left duplicated). POSIX-approximated semantics, shared verbatim by both
|
|
1525
|
-
* consumers: outside quotes `\` escapes the next character; `'...'`
|
|
1526
|
-
* closes only at a literal `'` (single quotes never honour backslash
|
|
1527
|
-
* escapes — the `escaped` branch below is unreachable whenever
|
|
1528
|
-
* `state === "'"`, by construction: `step` checks `this.escaped` BEFORE
|
|
1529
|
-
* checking `this.state`, so an escape flagged one character earlier is
|
|
1530
|
-
* always consumed first, but nothing inside the `state === "'"` branch
|
|
1531
|
-
* itself ever SETS `escaped`, so it can never observe a `\` as anything
|
|
1532
|
-
* other than a literal character); `"..."` closes at an unescaped `"`. A
|
|
1533
|
-
* mutable class rather than a pure step function returning a new state
|
|
1534
|
-
* object: this module runs on every Bash/Edit/Write tool call (module
|
|
1535
|
-
* header) and both consumers step through EVERY character of a segment or
|
|
1536
|
-
* token, so one allocation per scan (not one per character) matters for
|
|
1537
|
-
* the same "allocation-light" reason the module header states elsewhere.
|
|
1538
|
-
*/
|
|
1539
|
-
class QuoteScanner {
|
|
1540
|
-
state = "";
|
|
1541
|
-
escaped = false;
|
|
1542
|
-
/** Advance the state machine by exactly one character. */
|
|
1543
|
-
step(c) {
|
|
1544
|
-
if (this.escaped) {
|
|
1545
|
-
this.escaped = false;
|
|
1546
|
-
return;
|
|
1547
|
-
}
|
|
1548
|
-
if (this.state === "'") {
|
|
1549
|
-
if (c === "'")
|
|
1550
|
-
this.state = "";
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
if (this.state === '"') {
|
|
1554
|
-
if (c === "\\")
|
|
1555
|
-
this.escaped = true;
|
|
1556
|
-
else if (c === '"')
|
|
1557
|
-
this.state = "";
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1560
|
-
// state === ""
|
|
1561
|
-
if (c === "\\")
|
|
1562
|
-
this.escaped = true;
|
|
1563
|
-
else if (c === "'" || c === '"')
|
|
1564
|
-
this.state = c;
|
|
1565
|
-
}
|
|
1566
|
-
/**
|
|
1567
|
-
* Reset to the initial `""` / not-escaped state — behaviourally
|
|
1568
|
-
* IDENTICAL to discarding this instance and constructing a fresh one
|
|
1569
|
-
* (task cf3dff51, latency fix round: lets `findNextBoundaryQuoteAware`
|
|
1570
|
-
* reuse ONE module-level instance across every segment of a command
|
|
1571
|
-
* instead of allocating a new one per segment — see that function's own
|
|
1572
|
-
* comment for the measured latency this closes).
|
|
1573
|
-
*/
|
|
1574
|
-
reset() {
|
|
1575
|
-
this.state = "";
|
|
1576
|
-
this.escaped = false;
|
|
1577
|
-
}
|
|
1578
|
-
/**
|
|
1579
|
-
* Clear a pending escape without consuming a character. Used only by
|
|
1580
|
-
* `consumeAssignment`, at a whitespace-tokenised TOKEN boundary: a
|
|
1581
|
-
* backslash pending at a token's end would otherwise escape the
|
|
1582
|
-
* separating whitespace itself (task b093911d's class, deliberately
|
|
1583
|
-
* NOT continued — see that function's own comment). `state` is never
|
|
1584
|
-
* touched by this — an open quote genuinely stays open across
|
|
1585
|
-
* whitespace, only a dangling escape does not.
|
|
1586
|
-
*/
|
|
1587
|
-
resetEscape() {
|
|
1588
|
-
this.escaped = false;
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
/**
|
|
1592
|
-
* Module-level `QuoteScanner` instance reused by `findNextBoundaryQuoteAware`
|
|
1593
|
-
* across every segment of every command (task cf3dff51, latency fix
|
|
1594
|
-
* round) — `.reset()` at the top of each call makes reusing this single
|
|
1595
|
-
* instance behaviourally IDENTICAL to `new QuoteScanner()` per call (see
|
|
1596
|
-
* `reset()`'s own doc comment), just without the allocation. Placed here,
|
|
1597
|
-
* textually AFTER `class QuoteScanner`, because a top-level `const`
|
|
1598
|
-
* initialiser referencing a class runs in real module-evaluation order —
|
|
1599
|
-
* `findNextBoundaryQuoteAware` itself sits ABOVE this point in the file
|
|
1600
|
-
* but is a hoisted function declaration whose BODY only runs when called,
|
|
1601
|
-
* by which time this line has already executed.
|
|
1602
|
-
*/
|
|
1603
|
-
const BOUNDARY_QUOTE_SCANNER = new QuoteScanner();
|
|
1604
|
-
/**
|
|
1605
|
-
* Consume one leading `VAR=value` assignment starting at `idx`, returning
|
|
1606
|
-
* the index of the first token AFTER it. Task 13e55484: the VALUE may be
|
|
1607
|
-
* quoted and span multiple whitespace-split tokens (`VAR='hello world'`),
|
|
1608
|
-
* which used to leave a dangling `world'` token that aborted the peel
|
|
1609
|
-
* loop — the one measured spelling where BOTH matching layers failed on
|
|
1610
|
-
* the same character. The continuation engages ONLY while an opening
|
|
1611
|
-
* quote from the assignment stays unbalanced at a token's end and a
|
|
1612
|
-
* matching close exists in a later token; every other shape keeps the
|
|
1613
|
-
* exact pre-task one-token consume:
|
|
1614
|
-
* - an UNTERMINATED quote (`VAR='a git push`) falls back to consuming
|
|
1615
|
-
* one token, because that spelling normalised to `git push` before
|
|
1616
|
-
* this task and must keep doing so (never-unmatch);
|
|
1617
|
-
* - backslash-escaped whitespace without quotes (`VAR=a\ b`) is task
|
|
1618
|
-
* b093911d's escape-handling class and is deliberately NOT continued
|
|
1619
|
-
* here (pinned as a still-open bypass in the test file).
|
|
1620
|
-
* Quote semantics per POSIX: outside quotes `\` escapes the next char,
|
|
1621
|
-
* single quotes close only at `'`, double quotes close at an unescaped
|
|
1622
|
-
* `"`. A quote run may chain (`VAR='a b'"c d"`); the assignment ends at
|
|
1623
|
-
* the first token that finishes outside any quote.
|
|
1624
|
-
*
|
|
1625
|
-
* ONE-DIRECTIONAL GUARD (review round 1, CRITICAL): the continuation
|
|
1626
|
-
* ABANDONS (returns the pre-task one-token consume) the moment the token
|
|
1627
|
-
* it would consume next is a recognised head token (`GIT_TOKEN_RE` or
|
|
1628
|
-
* `NON_GIT_HEAD_TOKENS`). Without this, a quote model diverging from
|
|
1629
|
-
* bash on even ONE spelling lets the continuation swallow the gated
|
|
1630
|
-
* command word and turn a previously-BLOCKED command into a bypass —
|
|
1631
|
-
* measured live with ANSI-C quoting, where bash escapes `\'` inside
|
|
1632
|
-
* `$'...'` but this scanner (correctly for plain `'...'`) treats the
|
|
1633
|
-
* backslash as literal: `A=$'don\'t' env harness pause # '` produced a
|
|
1634
|
-
* phantom-open state that consumed the whole segment, and NEITHER layer
|
|
1635
|
-
* matched where master's one-token consume blocked. The guard closes
|
|
1636
|
-
* the HEAD-SWALLOW channel specifically: a diverging quote state can
|
|
1637
|
-
* never re-target or swallow a recognised head token (measured — see
|
|
1638
|
-
* the differential pins in the test file). It is NOT a universal
|
|
1639
|
-
* monotonicity guarantee (round-2 review, measured): when the
|
|
1640
|
-
* continuation closes on a WRAPPER'S glued flag token, the peel resumes
|
|
1641
|
-
* at that wrapper's next ARGUMENT (a positional like timeout's
|
|
1642
|
-
* duration, or a second flag), which is neither head nor wrapper, so
|
|
1643
|
-
* the segment comes back unchanged where the pre-continuation peel
|
|
1644
|
-
* completed — e.g. `A='x timeout --signal=INT' 5 git push` matched on
|
|
1645
|
-
* master and does not here. Every found member of that class (550 of
|
|
1646
|
-
* 41,440 adversarial cases) is a MASTER FALSE POSITIVE, PATH-shim-
|
|
1647
|
-
* verified: bash treats the quoted run as one assignment word and then
|
|
1648
|
-
* executes the next word (`5`, `-e0`), never the gated verb — 110 are
|
|
1649
|
-
* outright `bash -n` syntax errors — so no gated invocation is lost and
|
|
1650
|
-
* this side is the more bash-accurate one; the class is pinned in the
|
|
1651
|
-
* test file so it cannot silently widen. The honest cost of the guard,
|
|
1652
|
-
* measured and named rather than implied: a quoted VALUE containing the
|
|
1653
|
-
* literal word `git`/`gh`/`npm`/`harness` as its own token
|
|
1654
|
-
* (`VAR='a git b' git push`) is not continued — the pre-task consume
|
|
1655
|
-
* applies, byte-identical to master's behaviour, so nothing regresses.
|
|
1656
|
-
* The guard checks EXACT head spellings only: a path-qualified head
|
|
1657
|
-
* glued to the closing quote (`VAR='a /usr/bin/git' git push` — the
|
|
1658
|
-
* token is `/usr/bin/git'`, which GIT_TOKEN_RE rejects) is NOT
|
|
1659
|
-
* abandoned, the continuation completes, and the REAL invocation behind
|
|
1660
|
-
* it now matches — a measured fail-closed gain over master, also
|
|
1661
|
-
* pinned.
|
|
1662
|
-
*/
|
|
1663
|
-
function consumeAssignment(tokens, idx) {
|
|
1664
|
-
const quote = new QuoteScanner();
|
|
1665
|
-
for (let i = idx; i < tokens.length; i++) {
|
|
1666
|
-
const text = tokens[i].text;
|
|
1667
|
-
if (i > idx && (GIT_TOKEN_RE.test(text) || NON_GIT_HEAD_TOKENS.has(text))) {
|
|
1668
|
-
return idx + 1;
|
|
1669
|
-
}
|
|
1670
|
-
for (let k = 0; k < text.length; k++) {
|
|
1671
|
-
quote.step(text[k]);
|
|
1672
|
-
}
|
|
1673
|
-
// A backslash pending at a token's end would escape the separating
|
|
1674
|
-
// whitespace itself — b093911d's class, not continued here.
|
|
1675
|
-
quote.resetEscape();
|
|
1676
|
-
if (quote.state === "")
|
|
1677
|
-
return i + 1;
|
|
1678
|
-
}
|
|
1679
|
-
// Ran out of tokens with a quote still open: unterminated. Preserve the
|
|
1680
|
-
// pre-task behaviour exactly (consume only the assignment's first token).
|
|
1681
|
-
return idx + 1;
|
|
1682
|
-
}
|
|
1683
|
-
/** Split a segment into whitespace-delimited tokens with their offsets. */
|
|
1684
|
-
function tokenizeWithOffsets(s) {
|
|
1685
|
-
const out = [];
|
|
1686
|
-
const re = /\S+/g;
|
|
1687
|
-
let m;
|
|
1688
|
-
while ((m = re.exec(s)) !== null) {
|
|
1689
|
-
out.push({ text: m[0], start: m.index, end: m.index + m[0].length });
|
|
1690
|
-
}
|
|
1691
|
-
return out;
|
|
1692
|
-
}
|
|
1693
|
-
/**
|
|
1694
|
-
* Peel a leading wrapper-command chain: `VAR=value` tokens (any number,
|
|
1695
|
-
* in sequence), `env` (+ its own flags/assignments, capturing `-C`/
|
|
1696
|
-
* `--chdir`), `command` (+ its own flags), `nice` (+ its own flags),
|
|
1697
|
-
* `sudo` / `doas` / `time` / `timeout` / `stdbuf` / `setsid` / `exec` /
|
|
1698
|
-
* `nohup` (+ each one's own flags — F4 fix; `exec`/`nohup` added task
|
|
1699
|
-
* d03af8f6, review round 3). Bounded by `tokens.length` so a
|
|
1700
|
-
* pathological input cannot spin. Returns the cursor position of
|
|
1701
|
-
* whatever token (if any) survives the peel — the caller decides what to
|
|
1702
|
-
* do with it; this function never itself inspects the survivor.
|
|
1703
|
-
*
|
|
1704
|
-
* SHARED (task d03af8f6, review round 3) between `canonicalizeSegment`
|
|
1705
|
-
* below — this module's own `git`/`gh`/`npm`/`harness` trigger
|
|
1706
|
-
* recognition — and `src/runtime/deletion-target-resolve.ts`'s deletion-
|
|
1707
|
-
* verb recognition (`rm`/`find`/`git clean`), which used to hand-roll an
|
|
1708
|
-
* independent, narrower copy of this exact peeling vocabulary
|
|
1709
|
-
* (`peelWrapperHeads`, missing `-u`/`-i`/`-C` on `env`, the duration-
|
|
1710
|
-
* consuming shape of `timeout`, `nice`'s glued/`--adjustment=` forms,
|
|
1711
|
-
* and `exec`/`nohup` entirely). Reusing this ONE loop instead of
|
|
1712
|
-
* maintaining two means a future wrapper added here (or a flag fix to an
|
|
1713
|
-
* existing one) benefits both gates automatically, and
|
|
1714
|
-
* `npm run check:duplication`'s clone-count fitness function has one
|
|
1715
|
-
* fewer near-duplicate block to flag. `deletion-target-resolve.ts` hands
|
|
1716
|
-
* this a decoded token array built from ITS OWN quote-aware tokenizer
|
|
1717
|
-
* (`WrapperPeelToken[]`, `.text` only — see that interface's own
|
|
1718
|
-
* comment for why the offset-carrying `Token[]` this module's own
|
|
1719
|
-
* `tokenizeWithOffsets` produces is not required here), then layers its
|
|
1720
|
-
* own `xargs` handling on top (this loop deliberately does NOT recognise
|
|
1721
|
-
* `xargs` — see the module header's NOT-SUPPORTED list for why `xargs`
|
|
1722
|
-
* is architecturally different from every wrapper peeled here: its own
|
|
1723
|
-
* argv is not simply "the command to run").
|
|
1724
|
-
*/
|
|
1725
|
-
export function peelWrapperPrefixes(tokens, startIdx = 0) {
|
|
1726
|
-
let idx = startIdx;
|
|
1727
|
-
let envTargetDir = null;
|
|
1728
|
-
for (let guard = 0; guard < tokens.length; guard++) {
|
|
1729
|
-
const head = tokens[idx]?.text;
|
|
1730
|
-
if (head === undefined)
|
|
1731
|
-
break;
|
|
1732
|
-
if (VAR_ASSIGN_RE.test(head)) {
|
|
1733
|
-
idx = consumeAssignment(tokens, idx);
|
|
1734
|
-
continue;
|
|
1735
|
-
}
|
|
1736
|
-
if (head === "env") {
|
|
1737
|
-
const r = peelEnv(tokens, idx + 1);
|
|
1738
|
-
idx = r.idx;
|
|
1739
|
-
if (envTargetDir === null)
|
|
1740
|
-
envTargetDir = r.targetDir;
|
|
1741
|
-
continue;
|
|
1742
|
-
}
|
|
1743
|
-
if (head === "command") {
|
|
1744
|
-
idx = peelCommand(tokens, idx + 1);
|
|
1745
|
-
continue;
|
|
1746
|
-
}
|
|
1747
|
-
if (head === "nice") {
|
|
1748
|
-
idx = peelNice(tokens, idx + 1);
|
|
1749
|
-
continue;
|
|
1750
|
-
}
|
|
1751
|
-
if (head === "sudo") {
|
|
1752
|
-
idx = peelSudo(tokens, idx + 1);
|
|
1753
|
-
continue;
|
|
1754
|
-
}
|
|
1755
|
-
if (head === "doas") {
|
|
1756
|
-
idx = peelDoas(tokens, idx + 1);
|
|
1757
|
-
continue;
|
|
1758
|
-
}
|
|
1759
|
-
if (head === "time") {
|
|
1760
|
-
idx = peelTime(tokens, idx + 1);
|
|
1761
|
-
continue;
|
|
1762
|
-
}
|
|
1763
|
-
if (head === "timeout") {
|
|
1764
|
-
idx = peelTimeout(tokens, idx + 1);
|
|
1765
|
-
continue;
|
|
1766
|
-
}
|
|
1767
|
-
if (head === "stdbuf") {
|
|
1768
|
-
idx = peelStdbuf(tokens, idx + 1);
|
|
1769
|
-
continue;
|
|
1770
|
-
}
|
|
1771
|
-
if (head === "setsid") {
|
|
1772
|
-
idx = peelSetsid(tokens, idx + 1);
|
|
1773
|
-
continue;
|
|
1774
|
-
}
|
|
1775
|
-
if (head === "exec") {
|
|
1776
|
-
idx = peelExec(tokens, idx + 1);
|
|
1777
|
-
continue;
|
|
1778
|
-
}
|
|
1779
|
-
if (head === "nohup") {
|
|
1780
|
-
idx = peelNohup(tokens, idx + 1);
|
|
1781
|
-
continue;
|
|
1782
|
-
}
|
|
1783
|
-
break;
|
|
1784
|
-
}
|
|
1785
|
-
return { idx, envTargetDir };
|
|
1786
|
-
}
|
|
1787
|
-
/**
|
|
1788
|
-
* Rewrite one boundary-delimited segment: peel wrapper prefixes to look
|
|
1789
|
-
* for an invocation of one of the closed head-token set's four members
|
|
1790
|
-
* (`git`, `gh`, `npm`, `harness` — D-001, run
|
|
1791
|
-
* 2026-07-28-nongit-trigger-wrappers), and — ONLY if one is found —
|
|
1792
|
-
* canonicalise it. `git` is canonicalised to `git <subcommand>` with its
|
|
1793
|
-
* OWN global options dropped, same as before this run. The other three
|
|
1794
|
-
* are canonicalised by wrapper peeling plus whitespace collapsing across
|
|
1795
|
-
* the REST OF THE SEGMENT (fix round 2, finding F2 — rejoining every
|
|
1796
|
-
* token from the head onward with a single space, not just the head-to-
|
|
1797
|
-
* next-token gap), no option-dropping (see the module header). When no
|
|
1798
|
-
* recognised head token is found (wrong binary, ran out of tokens, or a
|
|
1799
|
-
* malformed git global option), the segment is returned COMPLETELY
|
|
1800
|
-
* UNCHANGED: peeling is tentative, and nothing is stripped from a
|
|
1801
|
-
* segment that turns out not to be a recognised call (so `digit=1 foo`,
|
|
1802
|
-
* `env -C X ls`, `ghx pr merge`, etc. are never touched).
|
|
1803
|
-
*/
|
|
1804
|
-
function canonicalizeSegment(segmentText) {
|
|
1805
|
-
const tokens = tokenizeWithOffsets(segmentText);
|
|
1806
|
-
if (tokens.length === 0) {
|
|
1807
|
-
return {
|
|
1808
|
-
text: segmentText,
|
|
1809
|
-
targetDir: null,
|
|
1810
|
-
targetBase: null,
|
|
1811
|
-
identityTargetDir: null,
|
|
1812
|
-
identityTargetBase: null,
|
|
1813
|
-
isGit: false,
|
|
1814
|
-
};
|
|
1815
|
-
}
|
|
1816
|
-
const peeled = peelWrapperPrefixes(tokens);
|
|
1817
|
-
let idx = peeled.idx;
|
|
1818
|
-
const envTargetDir = peeled.envTargetDir;
|
|
1819
|
-
const headTok = tokens[idx]?.text;
|
|
1820
|
-
if (headTok === undefined) {
|
|
1821
|
-
return {
|
|
1822
|
-
text: segmentText,
|
|
1823
|
-
targetDir: null,
|
|
1824
|
-
targetBase: null,
|
|
1825
|
-
identityTargetDir: null,
|
|
1826
|
-
identityTargetBase: null,
|
|
1827
|
-
isGit: false,
|
|
1828
|
-
};
|
|
1829
|
-
}
|
|
1830
|
-
if (GIT_TOKEN_RE.test(headTok)) {
|
|
1831
|
-
idx += 1; // consume the git token (whatever spelling matched GIT_TOKEN_RE)
|
|
1832
|
-
const gitOpts = peelGitGlobalOptions(tokens, idx);
|
|
1833
|
-
if (gitOpts.malformed || gitOpts.idx >= tokens.length) {
|
|
1834
|
-
// Malformed global option (missing required value) or no subcommand
|
|
1835
|
-
// token left: nothing safe to canonicalise. Leave untouched.
|
|
1836
|
-
return {
|
|
1837
|
-
text: segmentText,
|
|
1838
|
-
targetDir: null,
|
|
1839
|
-
targetBase: null,
|
|
1840
|
-
identityTargetDir: null,
|
|
1841
|
-
identityTargetBase: null,
|
|
1842
|
-
isGit: false,
|
|
1843
|
-
};
|
|
1844
|
-
}
|
|
1845
|
-
const subcommandTok = tokens[gitOpts.idx];
|
|
1846
|
-
const rewritten = `git ${subcommandTok.text}${segmentText.slice(subcommandTok.end)}`;
|
|
1847
|
-
const targetDir = gitOpts.targetDir ?? envTargetDir;
|
|
1848
|
-
// F5: the git invocation's OWN target wins (already the priority
|
|
1849
|
-
// order above), but when it's RELATIVE and this same segment is
|
|
1850
|
-
// wrapped by `env -C`, that env target is the base it's relative to —
|
|
1851
|
-
// only meaningful when git's own target is the one actually used.
|
|
1852
|
-
const targetBase = gitOpts.targetDir !== null &&
|
|
1853
|
-
envTargetDir !== null &&
|
|
1854
|
-
!path.isAbsolute(gitOpts.targetDir)
|
|
1855
|
-
? envTargetDir
|
|
1856
|
-
: null;
|
|
1857
|
-
// D-017: the identity pair mirrors targetDir/targetBase's composition
|
|
1858
|
-
// exactly, but is fed by `relocateTargetDir` (repo-relocating flags
|
|
1859
|
-
// only — `-C`/`--git-dir`, never `--work-tree`) instead of
|
|
1860
|
-
// `gitOpts.targetDir` (which still includes `--work-tree`).
|
|
1861
|
-
//
|
|
1862
|
-
// D-018 (fix round 3): a same-invocation wrapping `env -C`/`--chdir`
|
|
1863
|
-
// PLUS the git invocation's OWN absolute repo-relocating flag are two
|
|
1864
|
-
// repo-relocating options on one invocation, the same "more than one"
|
|
1865
|
-
// class `noteRelocatingOption` (in `peelGitGlobalOptions`) closes for
|
|
1866
|
-
// git's own flags — extended here across the `env -C` wrap. When they
|
|
1867
|
-
// diverge (different resolved directories), git's own flag DOES
|
|
1868
|
-
// deterministically win in reality, but this module deliberately does
|
|
1869
|
-
// not encode that composition rule (`computeSegmentTarget`'s own
|
|
1870
|
-
// comment on why path composition is out of scope) — `null` (cwd
|
|
1871
|
-
// fallback, D-003) rather than keep preferring git's own flag as
|
|
1872
|
-
// before this fix. An identical pair (`env -C X git -C X ...`) is the
|
|
1873
|
-
// same idempotent-repeat allowance as `noteRelocatingOption`'s single-
|
|
1874
|
-
// function case, so it stays `X`, not `null`. The pre-existing
|
|
1875
|
-
// RELATIVE-own-target + env-base mix is untouched by this check —
|
|
1876
|
-
// `identityTargetBase` below already resolves that class fully to
|
|
1877
|
-
// `null` via `computeSegmentTarget`'s early return; this new check
|
|
1878
|
-
// only has any effect when `gitOpts.relocateTargetDir` is ABSOLUTE
|
|
1879
|
-
// (`path.isAbsolute` guards it out for the relative case, the same
|
|
1880
|
-
// condition `identityTargetBase` below already uses).
|
|
1881
|
-
const relocateEnvDiverges = gitOpts.relocateTargetDir !== null &&
|
|
1882
|
-
envTargetDir !== null &&
|
|
1883
|
-
path.isAbsolute(gitOpts.relocateTargetDir) &&
|
|
1884
|
-
gitOpts.relocateTargetDir !== envTargetDir;
|
|
1885
|
-
const identityTargetDir = relocateEnvDiverges
|
|
1886
|
-
? null
|
|
1887
|
-
: (gitOpts.relocateTargetDir ?? envTargetDir);
|
|
1888
|
-
const identityTargetBase = gitOpts.relocateTargetDir !== null &&
|
|
1889
|
-
envTargetDir !== null &&
|
|
1890
|
-
!path.isAbsolute(gitOpts.relocateTargetDir)
|
|
1891
|
-
? envTargetDir
|
|
1892
|
-
: null;
|
|
1893
|
-
return {
|
|
1894
|
-
text: rewritten,
|
|
1895
|
-
targetDir,
|
|
1896
|
-
targetBase,
|
|
1897
|
-
identityTargetDir,
|
|
1898
|
-
identityTargetBase,
|
|
1899
|
-
isGit: true,
|
|
1900
|
-
};
|
|
1901
|
-
}
|
|
1902
|
-
if (NON_GIT_HEAD_TOKENS.has(headTok)) {
|
|
1903
|
-
// D-001 + fix round 2 (finding F2): `gh` / `npm` / `harness` get
|
|
1904
|
-
// wrapper peeling (already applied above, same loop as `git`'s) plus
|
|
1905
|
-
// whitespace collapsing ACROSS THE REST OF THE SEGMENT — rejoining
|
|
1906
|
-
// every token from the head token onward with exactly one space
|
|
1907
|
-
// between each. The original scope (collapse only between the head
|
|
1908
|
-
// token and the SINGLE token immediately following it) left an
|
|
1909
|
-
// INTERIOR whitespace run further into a multi-word trigger able to
|
|
1910
|
-
// defeat the match: `gh pr merge` (double space between `pr` and
|
|
1911
|
-
// `merge`, not `gh` and `pr`), `gh pr<TAB>merge`, `gh pr create`.
|
|
1912
|
-
// Full-segment rejoin closes any such run anywhere after the head,
|
|
1913
|
-
// not just immediately after it. Safe because this is additive-only
|
|
1914
|
-
// matching (raw-OR-normalised, module header) and byte-identity is an
|
|
1915
|
-
// acceptance criterion only for heads NOT in this set (see the
|
|
1916
|
-
// byte-identity negative tests below) — a RECOGNISED head's own tail
|
|
1917
|
-
// may be freely rewritten. Still no option-dropping (`gh -R`, `npm
|
|
1918
|
-
// --loglevel`, etc. stay unsupported — module header): a tool-specific
|
|
1919
|
-
// flag between the head and its subcommand still isn't recognised or
|
|
1920
|
-
// skipped, it just no longer needs an interior whitespace run to
|
|
1921
|
-
// survive the rejoin. `targetDir`/`targetBase` stay `null`: these are
|
|
1922
|
-
// never git invocations, so no per-invocation target is extracted for
|
|
1923
|
-
// them (unaffected by the STATUS note above — that extraction was
|
|
1924
|
-
// never wired to a gate either way).
|
|
1925
|
-
if (idx + 1 >= tokens.length) {
|
|
1926
|
-
// Head token alone, no further token to canonicalise toward — no
|
|
1927
|
-
// shipped trigger matches a bare head token anyway, and rewriting to
|
|
1928
|
-
// just the head would silently drop whatever wrapper preceded it for
|
|
1929
|
-
// no benefit. Leave untouched, same fail-safe shape as the git
|
|
1930
|
-
// malformed/no-subcommand case above.
|
|
1931
|
-
return {
|
|
1932
|
-
text: segmentText,
|
|
1933
|
-
targetDir: null,
|
|
1934
|
-
targetBase: null,
|
|
1935
|
-
identityTargetDir: null,
|
|
1936
|
-
identityTargetBase: null,
|
|
1937
|
-
isGit: false,
|
|
1938
|
-
};
|
|
1939
|
-
}
|
|
1940
|
-
const rewritten = tokens
|
|
1941
|
-
.slice(idx)
|
|
1942
|
-
.map((t) => t.text)
|
|
1943
|
-
.join(" ");
|
|
1944
|
-
return {
|
|
1945
|
-
text: rewritten,
|
|
1946
|
-
targetDir: null,
|
|
1947
|
-
targetBase: null,
|
|
1948
|
-
identityTargetDir: null,
|
|
1949
|
-
identityTargetBase: null,
|
|
1950
|
-
isGit: false,
|
|
1951
|
-
};
|
|
1952
|
-
}
|
|
1953
|
-
return {
|
|
1954
|
-
text: segmentText,
|
|
1955
|
-
targetDir: null,
|
|
1956
|
-
targetBase: null,
|
|
1957
|
-
identityTargetDir: null,
|
|
1958
|
-
identityTargetBase: null,
|
|
1959
|
-
isGit: false,
|
|
1960
|
-
};
|
|
1961
|
-
}
|
|
1962
|
-
/**
|
|
1963
|
-
* Peel `env`'s own leading flags and `VAR=value` assignments. The flag-
|
|
1964
|
-
* NAME sets are shared with `read-only-bash.ts` (F6 fix — see the module
|
|
1965
|
-
* header); the VALUE extraction for `-C`/`--chdir` (needed for
|
|
1966
|
-
* `targetDir`, not needed by that module) stays local. Returns the
|
|
1967
|
-
* cursor after the last recognised token and the `-C`/`--chdir` target
|
|
1968
|
-
* directory, if any (excluding a `~`-prefixed value — F5, treated as if
|
|
1969
|
-
* absent).
|
|
1970
|
-
*/
|
|
1971
|
-
function peelEnv(tokens, startIdx) {
|
|
1972
|
-
let idx = startIdx;
|
|
1973
|
-
let targetDir = null;
|
|
1974
|
-
while (idx < tokens.length) {
|
|
1975
|
-
const t = tokens[idx].text;
|
|
1976
|
-
// `-S`/`--split-string` re-parses a single string argument into a
|
|
1977
|
-
// fresh argv — we cannot safely see through it. Stop peeling here;
|
|
1978
|
-
// whatever follows is opaque, and since it is not the literal `git`
|
|
1979
|
-
// token the caller's git-check below fails harmlessly.
|
|
1980
|
-
if (ENV_SPLIT_STRING_FLAGS.test(t))
|
|
1981
|
-
break;
|
|
1982
|
-
if (t === "--") {
|
|
1983
|
-
idx += 1;
|
|
1984
|
-
break;
|
|
1985
|
-
}
|
|
1986
|
-
if (t === "-C" || t === "--chdir") {
|
|
1987
|
-
const dir = tokens[idx + 1]?.text;
|
|
1988
|
-
if (dir === undefined)
|
|
1989
|
-
break;
|
|
1990
|
-
if (targetDir === null && !isTildeTarget(dir))
|
|
1991
|
-
targetDir = dir;
|
|
1992
|
-
idx += 2;
|
|
1993
|
-
continue;
|
|
1994
|
-
}
|
|
1995
|
-
if (ENV_VALUE_FLAGS.has(t)) {
|
|
1996
|
-
// Reached only for `-u` / `--unset` — `-C` / `--chdir` are handled
|
|
1997
|
-
// above (with value capture) before this generic check runs.
|
|
1998
|
-
idx += 2;
|
|
1999
|
-
continue;
|
|
2000
|
-
}
|
|
2001
|
-
if (ENV_LEADING_FLAGS.has(t)) {
|
|
2002
|
-
idx += 1;
|
|
2003
|
-
continue;
|
|
2004
|
-
}
|
|
2005
|
-
if (t.startsWith("-C") && t.length > 2) {
|
|
2006
|
-
if (targetDir === null && !isTildeTarget(t.slice(2)))
|
|
2007
|
-
targetDir = t.slice(2);
|
|
2008
|
-
idx += 1;
|
|
2009
|
-
continue;
|
|
2010
|
-
}
|
|
2011
|
-
if (t.startsWith("--chdir=")) {
|
|
2012
|
-
const dir = t.slice("--chdir=".length);
|
|
2013
|
-
if (targetDir === null && !isTildeTarget(dir))
|
|
2014
|
-
targetDir = dir;
|
|
2015
|
-
idx += 1;
|
|
2016
|
-
continue;
|
|
2017
|
-
}
|
|
2018
|
-
if (t.startsWith("--unset=") || /^-u./.test(t)) {
|
|
2019
|
-
idx += 1;
|
|
2020
|
-
continue;
|
|
2021
|
-
}
|
|
2022
|
-
// Generic glued long flag with a value (`--default-signal=INT`, or
|
|
2023
|
-
// any OTHER `env` long option this module does not need the VALUE
|
|
2024
|
-
// of): single token, skip it (G6 fix, review round 2, 2026-07-27 —
|
|
2025
|
-
// mirrors `read-only-bash.ts`'s own generic catch-all, so the two
|
|
2026
|
-
// peelers' coverage cannot drift again the way `--default-signal=INT`
|
|
2027
|
-
// once did; see the module header). Placed AFTER the named `-C` /
|
|
2028
|
-
// `--chdir` / `--unset` forms above so it only ever catches a flag
|
|
2029
|
-
// this module has no more specific handling for.
|
|
2030
|
-
if (t.startsWith("--") && t.includes("=")) {
|
|
2031
|
-
idx += 1;
|
|
2032
|
-
continue;
|
|
2033
|
-
}
|
|
2034
|
-
if (VAR_ASSIGN_RE.test(t)) {
|
|
2035
|
-
idx = consumeAssignment(tokens, idx);
|
|
2036
|
-
continue;
|
|
2037
|
-
}
|
|
2038
|
-
break;
|
|
2039
|
-
}
|
|
2040
|
-
return { idx, targetDir };
|
|
2041
|
-
}
|
|
2042
|
-
/** Peel `command`'s own flags (e.g. `-p`, `-v`, `-V`, `--`). */
|
|
2043
|
-
function peelCommand(tokens, startIdx) {
|
|
2044
|
-
let idx = startIdx;
|
|
2045
|
-
while (idx < tokens.length) {
|
|
2046
|
-
const t = tokens[idx].text;
|
|
2047
|
-
if (t === "--") {
|
|
2048
|
-
idx += 1;
|
|
2049
|
-
break;
|
|
2050
|
-
}
|
|
2051
|
-
if (t.startsWith("-") && t !== "-") {
|
|
2052
|
-
idx += 1;
|
|
2053
|
-
continue;
|
|
2054
|
-
}
|
|
2055
|
-
break;
|
|
2056
|
-
}
|
|
2057
|
-
return idx;
|
|
2058
|
-
}
|
|
2059
|
-
/**
|
|
2060
|
-
* Peel `nice`'s own flags: `-n <n>`, `-n<n>`, `--adjustment=<n>`, and the
|
|
2061
|
-
* bare `-<n>` / `+<n>` forms (G3 fix, review round 2, 2026-07-27):
|
|
2062
|
-
* `nice(1)` documents `nice -10 cmd` (increment glued directly to the
|
|
2063
|
-
* leading dash, no `n`) as its PRIMARY spelling, and some BSD variants
|
|
2064
|
-
* also accept a leading `+` for a positive increment — `nice -10 git
|
|
2065
|
-
* status` bypassed even though `nice` was already in the SUPPORTED list,
|
|
2066
|
-
* because only the `-n`-prefixed spellings were recognised.
|
|
2067
|
-
*/
|
|
2068
|
-
function peelNice(tokens, startIdx) {
|
|
2069
|
-
let idx = startIdx;
|
|
2070
|
-
while (idx < tokens.length) {
|
|
2071
|
-
const t = tokens[idx].text;
|
|
2072
|
-
if (t === "-n" || t === "--adjustment") {
|
|
2073
|
-
if (tokens[idx + 1] === undefined)
|
|
2074
|
-
break;
|
|
2075
|
-
idx += 2;
|
|
2076
|
-
continue;
|
|
2077
|
-
}
|
|
2078
|
-
if (/^-n-?\d+$/.test(t)) {
|
|
2079
|
-
idx += 1;
|
|
2080
|
-
continue;
|
|
2081
|
-
}
|
|
2082
|
-
if (t.startsWith("--adjustment=")) {
|
|
2083
|
-
idx += 1;
|
|
2084
|
-
continue;
|
|
2085
|
-
}
|
|
2086
|
-
if (/^[-+]\d+$/.test(t)) {
|
|
2087
|
-
idx += 1;
|
|
2088
|
-
continue;
|
|
2089
|
-
}
|
|
2090
|
-
break;
|
|
2091
|
-
}
|
|
2092
|
-
return idx;
|
|
2093
|
-
}
|
|
2094
|
-
/**
|
|
2095
|
-
* Generic "peel this wrapper's own flags" loop shared by the small,
|
|
2096
|
-
* bounded-vocabulary runners added for F4 (`sudo`, `doas`, `time`,
|
|
2097
|
-
* `stdbuf`, `setsid`, `exec`, `nohup`): consume `--`, any flag in
|
|
2098
|
-
* `valueFlags` (+ its following token), any glued short/long form
|
|
2099
|
-
* matching `gluedRe`, then any OTHER `-`-prefixed token treated as
|
|
2100
|
-
* boolean (mirrors `peelCommand` above — safe because an unrecognised
|
|
2101
|
-
* flag is either genuinely boolean, in which case this is correct, or
|
|
2102
|
-
* value-taking, in which case its VALUE token is left for the next
|
|
2103
|
-
* iteration and — not being `git`, a known wrapper, or another flag —
|
|
2104
|
-
* simply causes a clean, safe bail-out a token late rather than a false
|
|
2105
|
-
* match). Does NOT capture flag VALUES: none of these wrappers have a
|
|
2106
|
-
* "run in a different directory" semantic this module tracks as a
|
|
2107
|
-
* target directory — only `env -C`/`--chdir` does, and that stays
|
|
2108
|
-
* hand-written in `peelEnv`. Exported (task d03af8f6, review round 3) so
|
|
2109
|
-
* `src/runtime/deletion-target-resolve.ts` can reuse this SAME loop for
|
|
2110
|
-
* `xargs`'s own flags (`-0`, `-n1`, ...) instead of a third
|
|
2111
|
-
* independently-drifting copy — that module is the reason this needs no
|
|
2112
|
-
* value-flag vocabulary of its own either: `xargs`'s own value-taking
|
|
2113
|
-
* flags are not modelled there, matching this function's existing
|
|
2114
|
-
* effort level for the boolean-only wrappers above.
|
|
2115
|
-
*/
|
|
2116
|
-
export function peelGenericFlags(tokens, startIdx, valueFlags, gluedRe) {
|
|
2117
|
-
let idx = startIdx;
|
|
2118
|
-
while (idx < tokens.length) {
|
|
2119
|
-
const t = tokens[idx].text;
|
|
2120
|
-
if (t === "--") {
|
|
2121
|
-
idx += 1;
|
|
2122
|
-
break;
|
|
2123
|
-
}
|
|
2124
|
-
if (valueFlags.has(t)) {
|
|
2125
|
-
if (tokens[idx + 1] === undefined)
|
|
2126
|
-
break;
|
|
2127
|
-
idx += 2;
|
|
2128
|
-
continue;
|
|
2129
|
-
}
|
|
2130
|
-
if (t.startsWith("--") && t.includes("=")) {
|
|
2131
|
-
idx += 1;
|
|
2132
|
-
continue;
|
|
2133
|
-
}
|
|
2134
|
-
if (gluedRe !== null && gluedRe.test(t)) {
|
|
2135
|
-
idx += 1;
|
|
2136
|
-
continue;
|
|
2137
|
-
}
|
|
2138
|
-
if (t.startsWith("-") && t !== "-") {
|
|
2139
|
-
idx += 1;
|
|
2140
|
-
continue;
|
|
2141
|
-
}
|
|
2142
|
-
break;
|
|
2143
|
-
}
|
|
2144
|
-
return idx;
|
|
2145
|
-
}
|
|
2146
|
-
/**
|
|
2147
|
-
* `sudo`'s own value-taking flags (user/group/prompt/chroot/role/type/
|
|
2148
|
-
* timeout/close-from). Every other `-`-prefixed `sudo` flag (`-E`, `-H`,
|
|
2149
|
-
* `-i`, `-k`, `-n`, `-S`, `-v`, ...) is boolean and falls through
|
|
2150
|
-
* `peelGenericFlags`'s generic "-flag consumes one token" branch.
|
|
2151
|
-
*/
|
|
2152
|
-
const SUDO_VALUE_FLAGS = new Set([
|
|
2153
|
-
"-g", "-h", "-p", "-R", "-r", "-t", "-T", "-u", "-C",
|
|
2154
|
-
"--group", "--host", "--prompt", "--chroot", "--role",
|
|
2155
|
-
"--type", "--command-timeout", "--user", "--close-from",
|
|
2156
|
-
]);
|
|
2157
|
-
/** `sudo`'s glued short-value forms: `-uroot`, `-Cbastion`, etc. */
|
|
2158
|
-
const SUDO_GLUED_RE = /^-[ghpRrtTuC]./;
|
|
2159
|
-
function peelSudo(tokens, startIdx) {
|
|
2160
|
-
return peelGenericFlags(tokens, startIdx, SUDO_VALUE_FLAGS, SUDO_GLUED_RE);
|
|
2161
|
-
}
|
|
2162
|
-
/** `doas`'s own value-taking flags: `-u user`, `-C configfile`. */
|
|
2163
|
-
const DOAS_VALUE_FLAGS = new Set(["-u", "-C"]);
|
|
2164
|
-
const DOAS_GLUED_RE = /^-[uC]./;
|
|
2165
|
-
function peelDoas(tokens, startIdx) {
|
|
2166
|
-
return peelGenericFlags(tokens, startIdx, DOAS_VALUE_FLAGS, DOAS_GLUED_RE);
|
|
2167
|
-
}
|
|
2168
|
-
/** `time`'s own value-taking flags: `-o`/`--output`, `-f`/`--format`. */
|
|
2169
|
-
const TIME_VALUE_FLAGS = new Set(["-o", "-f", "--output", "--format"]);
|
|
2170
|
-
const TIME_GLUED_RE = /^-[of]./;
|
|
2171
|
-
function peelTime(tokens, startIdx) {
|
|
2172
|
-
return peelGenericFlags(tokens, startIdx, TIME_VALUE_FLAGS, TIME_GLUED_RE);
|
|
2173
|
-
}
|
|
2174
|
-
/** `timeout`'s own value-taking flags: `-k`/`--kill-after`, `-s`/`--signal`. */
|
|
2175
|
-
const TIMEOUT_VALUE_FLAGS = new Set([
|
|
2176
|
-
"-k", "-s", "--kill-after", "--signal",
|
|
2177
|
-
]);
|
|
2178
|
-
const TIMEOUT_GLUED_RE = /^-[ks]./;
|
|
2179
|
-
/**
|
|
2180
|
-
* `timeout DURATION command...` requires a DURATION positional BEFORE
|
|
2181
|
-
* the wrapped command — unlike the other four runners, so it cannot
|
|
2182
|
-
* share `peelGenericFlags` unmodified. Peel `timeout`'s own flags first,
|
|
2183
|
-
* then consume the mandatory duration token. A missing duration
|
|
2184
|
-
* (malformed/truncated command) leaves `idx` where the flag loop left
|
|
2185
|
-
* it; the caller's "is this token `git`?" check bails safely on
|
|
2186
|
-
* whatever token is actually there.
|
|
2187
|
-
*/
|
|
2188
|
-
function peelTimeout(tokens, startIdx) {
|
|
2189
|
-
let idx = peelGenericFlags(tokens, startIdx, TIMEOUT_VALUE_FLAGS, TIMEOUT_GLUED_RE);
|
|
2190
|
-
if (tokens[idx] !== undefined)
|
|
2191
|
-
idx += 1;
|
|
2192
|
-
return idx;
|
|
2193
|
-
}
|
|
2194
|
-
/** `stdbuf`'s flags are ALWAYS value-taking: `-i`/`-o`/`-e` (buffering mode). */
|
|
2195
|
-
const STDBUF_VALUE_FLAGS = new Set([
|
|
2196
|
-
"-i", "-o", "-e", "--input", "--output", "--error",
|
|
2197
|
-
]);
|
|
2198
|
-
const STDBUF_GLUED_RE = /^-[ioe]./;
|
|
2199
|
-
function peelStdbuf(tokens, startIdx) {
|
|
2200
|
-
return peelGenericFlags(tokens, startIdx, STDBUF_VALUE_FLAGS, STDBUF_GLUED_RE);
|
|
2201
|
-
}
|
|
2202
|
-
/** `setsid`'s flags (`-w`/`--wait`, `-c`/`--ctty`, `-f`/`--fork`) are all boolean. */
|
|
2203
|
-
function peelSetsid(tokens, startIdx) {
|
|
2204
|
-
return peelGenericFlags(tokens, startIdx, new Set(), null);
|
|
2205
|
-
}
|
|
2206
|
-
/**
|
|
2207
|
-
* `exec`'s own flags (bash builtin: `-a name`, `-c`, `-l`) are rare
|
|
2208
|
-
* enough that this module treats it as a boolean-only wrapper like
|
|
2209
|
-
* `setsid` above — an unrecognised flag (including `-a`, whose VALUE
|
|
2210
|
-
* this does not capture) simply consumes one token via
|
|
2211
|
-
* `peelGenericFlags`'s generic `-flag` branch (task d03af8f6, review
|
|
2212
|
-
* round 3 — closes the `exec git status` / `exec rm -rf ...` bypass
|
|
2213
|
-
* named in the module header's NOT-SUPPORTED list).
|
|
2214
|
-
*/
|
|
2215
|
-
function peelExec(tokens, startIdx) {
|
|
2216
|
-
return peelGenericFlags(tokens, startIdx, new Set(), null);
|
|
2217
|
-
}
|
|
2218
|
-
/** `nohup` takes no flags of its own (GNU coreutils: only `--help`/`--version`,
|
|
2219
|
-
* neither of which precedes a real wrapped command). Boolean-only, same
|
|
2220
|
-
* shape as `peelSetsid`/`peelExec` (task d03af8f6, review round 3 — closes
|
|
2221
|
-
* the `nohup git status` / `nohup rm -rf ... &` bypass). */
|
|
2222
|
-
function peelNohup(tokens, startIdx) {
|
|
2223
|
-
return peelGenericFlags(tokens, startIdx, new Set(), null);
|
|
2224
|
-
}
|
|
2225
|
-
/** Strip a path that ends in a `.git` directory down to its parent. */
|
|
2226
|
-
function parentIfDotGit(dir) {
|
|
2227
|
-
const trimmed = dir.replace(/\/+$/, "");
|
|
2228
|
-
if (trimmed === ".git")
|
|
2229
|
-
return ".";
|
|
2230
|
-
if (trimmed.endsWith("/.git"))
|
|
2231
|
-
return trimmed.slice(0, -"/.git".length) || "/";
|
|
2232
|
-
return dir;
|
|
2233
|
-
}
|
|
2234
|
-
/**
|
|
2235
|
-
* Peel git's own global options (dropping them so the subcommand becomes
|
|
2236
|
-
* adjacent to `git`), tracking the first `-C` / `--work-tree` /
|
|
2237
|
-
* `--git-dir` target directory encountered (excluding a `~`-prefixed
|
|
2238
|
-
* value — F5, treated as if absent, but still consumed as an option so
|
|
2239
|
-
* parsing continues past it) as `targetDir` — the OLD, UNCONSUMED
|
|
2240
|
-
* aggregate's own extraction (module header STATUS note), UNCHANGED by
|
|
2241
|
-
* the fix below. `malformed: true` means a value-requiring option was
|
|
2242
|
-
* missing its value — the caller bails without rewriting rather than
|
|
2243
|
-
* guess.
|
|
2244
|
-
*
|
|
2245
|
-
* `relocateTargetDir` (D-017, fix round 2, run `2026-08-02-per-repo-
|
|
2246
|
-
* gate-scoping-redesign`): a SEPARATE tracker, fed ONLY by the
|
|
2247
|
-
* repo-relocating flags `-C` and `--git-dir` — NEVER `--work-tree`.
|
|
2248
|
-
* `--work-tree` sets the working tree but does NOT relocate git's
|
|
2249
|
-
* `--git-dir` search; `git push`/`log`/`status` under `--work-tree=<B>`
|
|
2250
|
-
* alone still operate on whatever repo the CURRENT directory (or an
|
|
2251
|
-
* actual `-C`/`--git-dir`) names, not `<B>`. Pass-2 review measured this
|
|
2252
|
-
* as a live cross-repo fail-open once `--work-tree` was folded into the
|
|
2253
|
-
* same `targetDir` bucket as `-C`/`--git-dir` and that bucket fed
|
|
2254
|
-
* `CommandSegment.ownTarget`'s REPLACE attribution (`computeSegmentTarget`
|
|
2255
|
-
* below, `src/runtime/intercept.ts`'s `resolveAttributedContexts`,
|
|
2256
|
-
* D-011): `git --work-tree=<B> push`, cwd = A, was attributed to B alone,
|
|
2257
|
-
* dropping the cwd demand a real `git push` there still has to satisfy.
|
|
2258
|
-
* `relocateTargetDir` is what `computeSegmentTarget` now consults for a
|
|
2259
|
-
* git segment's `ownTarget` — see that function and `canonicalizeSegment`
|
|
2260
|
-
* below. Kept as a distinct field (a whitelist of repo-relocating flags),
|
|
2261
|
-
* not a `--work-tree` special-case, so a future path-valued-but-repo-
|
|
2262
|
-
* neutral git global option cannot silently reopen this class by being
|
|
2263
|
-
* folded into `targetDir` the way `--work-tree` originally was.
|
|
2264
|
-
*
|
|
2265
|
-
* D-018 (fix round 3, pass-3 CRITICAL): `relocateTargetDir` is set ONLY
|
|
2266
|
-
* when the invocation carries EXACTLY ONE recognised, fully-resolved
|
|
2267
|
-
* repo-relocating option (a single `-C` or a single `--git-dir`/
|
|
2268
|
-
* `--git-dir=`). Pass-3 review measured (against real git 2.34.1) that
|
|
2269
|
-
* `git -C <forge> -C <realA> push` attributes to the FIRST `-C` here
|
|
2270
|
-
* while git composes ALL of them cumulatively and actually operates in
|
|
2271
|
-
* `<realA>` — the same first-token-wins gap spans `-C` combined with
|
|
2272
|
-
* `--git-dir`. `noteRelocatingOption` below counts every RESOLVED
|
|
2273
|
-
* (non-`~`) occurrence of either flag across the whole invocation; the
|
|
2274
|
-
* second (or later) occurrence forces `relocateTargetDir` to `null` and
|
|
2275
|
-
* LOCKS it there for the rest of this call — UNLESS its value is an
|
|
2276
|
-
* exact repeat of the one already recorded (idempotent, e.g. `-C X -C
|
|
2277
|
-
* X`), which is not new information and does not trigger the lock. This
|
|
2278
|
-
* is a deliberate under-approximation of git's real `-C`-chains-relative-
|
|
2279
|
-
* to-the-previous-`-C` composition rule (the module has never encoded
|
|
2280
|
-
* that, by design — see `computeSegmentTarget`'s own comment on why
|
|
2281
|
-
* composing paths is not this module's job): ANY invocation this module
|
|
2282
|
-
* cannot resolve to a single, unambiguous flag falls back to `null` here,
|
|
2283
|
-
* which downstream (`computeSegmentTarget`) means "no own target" and the
|
|
2284
|
-
* cwd context is demanded instead — exactly the shipped, pre-this-run
|
|
2285
|
-
* semantics, never a forged first-token guess.
|
|
2286
|
-
*
|
|
2287
|
-
* D-021 (UNIVERSAL-ADDITIVE, operator decision, run 2026-08-02-per-repo-
|
|
2288
|
-
* gate-scoping-redesign): both the D-017 `--work-tree` exclusion and the
|
|
2289
|
-
* D-018 multi-option lock above were originally the SAFETY mechanism
|
|
2290
|
-
* (the thing standing between an attacker-composed invocation and a
|
|
2291
|
-
* forged REPLACE attribution); under D-021, `src/runtime/intercept.ts`'s
|
|
2292
|
-
* `resolveAttributedContexts` never replaces the cwd demand at all, so
|
|
2293
|
-
* neither exclusion can be bypassed into a weaker-than-shipped gate any
|
|
2294
|
-
* more — the cwd demand is never dropped regardless of what
|
|
2295
|
-
* `relocateTargetDir` resolves to. Both stay in place as PRECISION
|
|
2296
|
-
* (avoiding a spurious extra demand for an ambiguous or non-relocating
|
|
2297
|
-
* target), not as the security boundary; see `resolveAttributedContexts`
|
|
2298
|
-
* itself for where that boundary now lives.
|
|
2299
|
-
*/
|
|
2300
|
-
function peelGitGlobalOptions(tokens, startIdx) {
|
|
2301
|
-
let idx = startIdx;
|
|
2302
|
-
let targetDir = null;
|
|
2303
|
-
let relocateTargetDir = null;
|
|
2304
|
-
let relocateResolvedCount = 0;
|
|
2305
|
-
let relocateAmbiguous = false;
|
|
2306
|
-
// D-018: record one repo-relocating flag's resolved value (`null` for a
|
|
2307
|
-
// `~`-prefixed value, which — same as the pre-fix code — never sets or
|
|
2308
|
-
// counts against `relocateTargetDir` at all, D-003's "treated as if
|
|
2309
|
-
// absent"). The SECOND (or later) resolved occurrence nulls and LOCKS
|
|
2310
|
-
// `relocateTargetDir`, unless it is an exact repeat of the value
|
|
2311
|
-
// already recorded (idempotent, e.g. `-C X -C X` or `-C X --git-dir
|
|
2312
|
-
// X/.git`), which stays a single value rather than tripping the lock —
|
|
2313
|
-
// "im Zweifel null" still applies to every OTHER two-or-more case.
|
|
2314
|
-
function noteRelocatingOption(resolved) {
|
|
2315
|
-
if (resolved === null)
|
|
2316
|
-
return;
|
|
2317
|
-
relocateResolvedCount += 1;
|
|
2318
|
-
if (relocateAmbiguous)
|
|
2319
|
-
return;
|
|
2320
|
-
if (relocateResolvedCount === 1) {
|
|
2321
|
-
relocateTargetDir = resolved;
|
|
2322
|
-
return;
|
|
2323
|
-
}
|
|
2324
|
-
if (resolved === relocateTargetDir)
|
|
2325
|
-
return; // idempotent repeat
|
|
2326
|
-
relocateTargetDir = null;
|
|
2327
|
-
relocateAmbiguous = true;
|
|
2328
|
-
}
|
|
2329
|
-
while (idx < tokens.length) {
|
|
2330
|
-
const t = tokens[idx].text;
|
|
2331
|
-
if (t === "-C") {
|
|
2332
|
-
const dir = tokens[idx + 1]?.text;
|
|
2333
|
-
if (dir === undefined)
|
|
2334
|
-
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
2335
|
-
if (targetDir === null && !isTildeTarget(dir))
|
|
2336
|
-
targetDir = dir;
|
|
2337
|
-
noteRelocatingOption(isTildeTarget(dir) ? null : dir);
|
|
2338
|
-
idx += 2;
|
|
2339
|
-
continue;
|
|
2340
|
-
}
|
|
2341
|
-
if (t === "-c") {
|
|
2342
|
-
if (tokens[idx + 1] === undefined)
|
|
2343
|
-
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
2344
|
-
idx += 2;
|
|
2345
|
-
continue;
|
|
2346
|
-
}
|
|
2347
|
-
if (t === "--git-dir") {
|
|
2348
|
-
const dir = tokens[idx + 1]?.text;
|
|
2349
|
-
if (dir === undefined)
|
|
2350
|
-
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
2351
|
-
if (targetDir === null && !isTildeTarget(dir))
|
|
2352
|
-
targetDir = parentIfDotGit(dir);
|
|
2353
|
-
noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
|
|
2354
|
-
idx += 2;
|
|
2355
|
-
continue;
|
|
2356
|
-
}
|
|
2357
|
-
if (t.startsWith("--git-dir=")) {
|
|
2358
|
-
const dir = t.slice("--git-dir=".length);
|
|
2359
|
-
if (targetDir === null && !isTildeTarget(dir))
|
|
2360
|
-
targetDir = parentIfDotGit(dir);
|
|
2361
|
-
noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
|
|
2362
|
-
idx += 1;
|
|
2363
|
-
continue;
|
|
2364
|
-
}
|
|
2365
|
-
if (t === "--work-tree") {
|
|
2366
|
-
// D-017: `--work-tree` still feeds the OLD `targetDir` aggregate
|
|
2367
|
-
// (unchanged, unconsumed by any gate) but deliberately NEVER
|
|
2368
|
-
// `relocateTargetDir` — see this function's own doc comment.
|
|
2369
|
-
const dir = tokens[idx + 1]?.text;
|
|
2370
|
-
if (dir === undefined)
|
|
2371
|
-
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
2372
|
-
if (targetDir === null && !isTildeTarget(dir))
|
|
2373
|
-
targetDir = dir;
|
|
2374
|
-
idx += 2;
|
|
2375
|
-
continue;
|
|
2376
|
-
}
|
|
2377
|
-
if (t.startsWith("--work-tree=")) {
|
|
2378
|
-
const dir = t.slice("--work-tree=".length);
|
|
2379
|
-
if (targetDir === null && !isTildeTarget(dir))
|
|
2380
|
-
targetDir = dir;
|
|
2381
|
-
idx += 1;
|
|
2382
|
-
continue;
|
|
2383
|
-
}
|
|
2384
|
-
if (t === "--no-pager" || t === "-p" || t === "--paginate") {
|
|
2385
|
-
idx += 1;
|
|
2386
|
-
continue;
|
|
2387
|
-
}
|
|
2388
|
-
if (t === "--exec-path" || t.startsWith("--exec-path=")) {
|
|
2389
|
-
idx += 1;
|
|
2390
|
-
continue;
|
|
2391
|
-
}
|
|
2392
|
-
if (t === "--namespace") {
|
|
2393
|
-
if (tokens[idx + 1] === undefined)
|
|
2394
|
-
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
2395
|
-
idx += 2;
|
|
2396
|
-
continue;
|
|
2397
|
-
}
|
|
2398
|
-
if (t.startsWith("--namespace=")) {
|
|
2399
|
-
idx += 1;
|
|
2400
|
-
continue;
|
|
2401
|
-
}
|
|
2402
|
-
if (t === "--literal-pathspecs" || t === "--no-replace-objects") {
|
|
2403
|
-
idx += 1;
|
|
2404
|
-
continue;
|
|
2405
|
-
}
|
|
2406
|
-
break;
|
|
2407
|
-
}
|
|
2408
|
-
return { idx, targetDir, relocateTargetDir, malformed: false };
|
|
2409
|
-
}
|
|
2410
|
-
//# sourceMappingURL=command-normalize.js.map
|