@quickchatai/pi-quickreview 0.0.0-stage → 1.1.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/LICENSE +23 -0
- package/README.md +75 -2
- package/assets/agents/pass-abstraction.md +64 -0
- package/assets/agents/pass-ask-human.md +134 -0
- package/assets/agents/pass-backcompat.md +67 -0
- package/assets/agents/pass-baseline.md +61 -0
- package/assets/agents/pass-behaviour-delta.md +64 -0
- package/assets/agents/pass-bounds.md +36 -0
- package/assets/agents/pass-claims.md +31 -0
- package/assets/agents/pass-concurrency.md +168 -0
- package/assets/agents/pass-consistency.md +57 -0
- package/assets/agents/pass-contracts.md +30 -0
- package/assets/agents/pass-data-safety.md +54 -0
- package/assets/agents/pass-deferred.md +35 -0
- package/assets/agents/pass-delivery.md +37 -0
- package/assets/agents/pass-duplication.md +60 -0
- package/assets/agents/pass-intent.md +63 -0
- package/assets/agents/pass-lint-conformance.md +46 -0
- package/assets/agents/pass-maintainability.md +67 -0
- package/assets/agents/pass-placement.md +68 -0
- package/assets/agents/pass-prod-risk.md +24 -0
- package/assets/agents/pass-rollout.md +32 -0
- package/assets/agents/pass-roundtrip.md +46 -0
- package/assets/agents/pass-security.md +31 -0
- package/assets/agents/pass-simplicity.md +68 -0
- package/assets/agents/pass-statelessness.md +27 -0
- package/assets/agents/pass-style.md +37 -0
- package/assets/agents/pass-sweep.md +57 -0
- package/assets/agents/pass-tests.md +58 -0
- package/assets/agents/pass-ui.md +23 -0
- package/assets/agents/pass-validate.md +50 -0
- package/assets/agents/pass-widened.md +46 -0
- package/assets/agents/scan/pass-scan.md +86 -0
- package/assets/config.example.toml +1067 -0
- package/assets/deploy/reviewer/AGENTS.override.md +12 -0
- package/assets/profiles/default/README.md +21 -0
- package/assets/profiles/default/addenda/.gitkeep +0 -0
- package/assets/profiles/default/profile.toml +17 -0
- package/assets/profiles/example-django/README.md +21 -0
- package/assets/profiles/example-django/addenda/10-migration-discipline.md +13 -0
- package/assets/profiles/example-django/addenda/20-new-column-nullability.md +12 -0
- package/assets/profiles/example-django/addenda/30-test-doctrine.md +12 -0
- package/assets/profiles/example-django/addenda/40-orm-usage.md +19 -0
- package/assets/profiles/example-django/addenda/50-declared-shape.md +10 -0
- package/assets/profiles/example-django/addenda/60-config-placement.md +14 -0
- package/assets/profiles/example-django/heuristics.toml +24 -0
- package/assets/profiles/example-django/profile.toml +9 -0
- package/assets/profiles/fast/README.md +24 -0
- package/assets/profiles/fast/profile.toml +59 -0
- package/assets/profiles/minimal/README.md +17 -0
- package/assets/profiles/minimal/addenda/.gitkeep +0 -0
- package/assets/profiles/minimal/profile.toml +8 -0
- package/assets/profiles/snappy/README.md +24 -0
- package/assets/profiles/snappy/addenda/10-race-as-question.md +6 -0
- package/assets/profiles/snappy/profile.toml +46 -0
- package/assets/prompts/companion-day-open.md +46 -0
- package/assets/prompts/companion-first-run.md +104 -0
- package/assets/prompts/companion.md +821 -0
- package/assets/prompts/conversation-system.md +117 -0
- package/assets/prompts/eod-sweep.md +120 -0
- package/assets/prompts/onboarding.md +74 -0
- package/assets/prompts/pass-common.md +52 -0
- package/assets/prompts/pass-quality-common.md +22 -0
- package/assets/prompts/round-first.md +874 -0
- package/assets/prompts/round-follow-up.md +991 -0
- package/assets/prompts/round-re-review.md +889 -0
- package/assets/prompts/round-recheck.md +196 -0
- package/assets/prompts/round-staleness.md +856 -0
- package/assets/schemas/review-result.v1.json +391 -0
- package/assets/schemas/scan-result.v1.json +421 -0
- package/assets/schemas/version.v1.json +57 -0
- package/assets/seed-manifest.json +19 -0
- package/assets/seeds/agents/pass-baseline.md +61 -0
- package/assets/seeds/agents/pass-contracts.md +30 -0
- package/assets/seeds/agents/pass-data-safety.md +54 -0
- package/assets/seeds/agents/pass-delivery.md +37 -0
- package/assets/seeds/agents/pass-prod-risk.md +24 -0
- package/assets/seeds/agents/pass-security.md +31 -0
- package/assets/seeds/agents/pass-statelessness.md +27 -0
- package/assets/seeds/agents/pass-ui.md +23 -0
- package/assets/seeds/agents/pass-validate.md +50 -0
- package/assets/seeds/prompts/conversation-system.md +117 -0
- package/assets/seeds/prompts/eod-sweep.md +120 -0
- package/assets/seeds/prompts/pass-common.md +52 -0
- package/dist/extensions/bash-guard.d.ts +467 -0
- package/dist/extensions/bash-guard.d.ts.map +1 -0
- package/dist/extensions/bash-guard.js +3058 -0
- package/dist/extensions/bash-guard.js.map +1 -0
- package/dist/extensions/finding-clusters.d.ts +33 -0
- package/dist/extensions/finding-clusters.d.ts.map +1 -0
- package/dist/extensions/finding-clusters.js +218 -0
- package/dist/extensions/finding-clusters.js.map +1 -0
- package/dist/extensions/ledger-pin.d.ts +76 -0
- package/dist/extensions/ledger-pin.d.ts.map +1 -0
- package/dist/extensions/ledger-pin.js +331 -0
- package/dist/extensions/ledger-pin.js.map +1 -0
- package/dist/extensions/lens.d.ts +43 -0
- package/dist/extensions/lens.d.ts.map +1 -0
- package/dist/extensions/lens.js +150 -0
- package/dist/extensions/lens.js.map +1 -0
- package/dist/extensions/pi-tool.d.ts +22 -0
- package/dist/extensions/pi-tool.d.ts.map +1 -0
- package/dist/extensions/pi-tool.js +20 -0
- package/dist/extensions/pi-tool.js.map +1 -0
- package/dist/extensions/quickreview.d.ts +21 -0
- package/dist/extensions/quickreview.d.ts.map +1 -0
- package/dist/extensions/quickreview.js +132 -0
- package/dist/extensions/quickreview.js.map +1 -0
- package/dist/src/anchors.d.ts +54 -0
- package/dist/src/anchors.d.ts.map +1 -0
- package/dist/src/anchors.js +127 -0
- package/dist/src/anchors.js.map +1 -0
- package/dist/src/assetroot.d.ts +16 -0
- package/dist/src/assetroot.d.ts.map +1 -0
- package/dist/src/assetroot.js +77 -0
- package/dist/src/assetroot.js.map +1 -0
- package/dist/src/authwatch.d.ts +61 -0
- package/dist/src/authwatch.d.ts.map +1 -0
- package/dist/src/authwatch.js +210 -0
- package/dist/src/authwatch.js.map +1 -0
- package/dist/src/budget.d.ts +15 -0
- package/dist/src/budget.d.ts.map +1 -0
- package/dist/src/budget.js +32 -0
- package/dist/src/budget.js.map +1 -0
- package/dist/src/casebudget.d.ts +3 -0
- package/dist/src/casebudget.d.ts.map +1 -0
- package/dist/src/casebudget.js +3 -0
- package/dist/src/casebudget.js.map +1 -0
- package/dist/src/cigate.d.ts +116 -0
- package/dist/src/cigate.d.ts.map +1 -0
- package/dist/src/cigate.js +176 -0
- package/dist/src/cigate.js.map +1 -0
- package/dist/src/codespan.d.ts +46 -0
- package/dist/src/codespan.d.ts.map +1 -0
- package/dist/src/codespan.js +75 -0
- package/dist/src/codespan.js.map +1 -0
- package/dist/src/contextpack.d.ts +56 -0
- package/dist/src/contextpack.d.ts.map +1 -0
- package/dist/src/contextpack.js +195 -0
- package/dist/src/contextpack.js.map +1 -0
- package/dist/src/contract.d.ts +8 -0
- package/dist/src/contract.d.ts.map +1 -0
- package/dist/src/contract.js +21 -0
- package/dist/src/contract.js.map +1 -0
- package/dist/src/coverage.d.ts +32 -0
- package/dist/src/coverage.d.ts.map +1 -0
- package/dist/src/coverage.js +85 -0
- package/dist/src/coverage.js.map +1 -0
- package/dist/src/datafence.d.ts +19 -0
- package/dist/src/datafence.d.ts.map +1 -0
- package/dist/src/datafence.js +60 -0
- package/dist/src/datafence.js.map +1 -0
- package/dist/src/dedup.d.ts +65 -0
- package/dist/src/dedup.d.ts.map +1 -0
- package/dist/src/dedup.js +459 -0
- package/dist/src/dedup.js.map +1 -0
- package/dist/src/depsources.d.ts +304 -0
- package/dist/src/depsources.d.ts.map +1 -0
- package/dist/src/depsources.js +3367 -0
- package/dist/src/depsources.js.map +1 -0
- package/dist/src/diffinput.d.ts +10 -0
- package/dist/src/diffinput.d.ts.map +1 -0
- package/dist/src/diffinput.js +172 -0
- package/dist/src/diffinput.js.map +1 -0
- package/dist/src/doubtwords.d.ts +10 -0
- package/dist/src/doubtwords.d.ts.map +1 -0
- package/dist/src/doubtwords.js +13 -0
- package/dist/src/doubtwords.js.map +1 -0
- package/dist/src/ecosystem.d.ts +148 -0
- package/dist/src/ecosystem.d.ts.map +1 -0
- package/dist/src/ecosystem.js +338 -0
- package/dist/src/ecosystem.js.map +1 -0
- package/dist/src/engine/agentdir.d.ts +5 -0
- package/dist/src/engine/agentdir.d.ts.map +1 -0
- package/dist/src/engine/agentdir.js +21 -0
- package/dist/src/engine/agentdir.js.map +1 -0
- package/dist/src/engine/auth.d.ts +214 -0
- package/dist/src/engine/auth.d.ts.map +1 -0
- package/dist/src/engine/auth.js +374 -0
- package/dist/src/engine/auth.js.map +1 -0
- package/dist/src/engine/budget.d.ts +60 -0
- package/dist/src/engine/budget.d.ts.map +1 -0
- package/dist/src/engine/budget.js +113 -0
- package/dist/src/engine/budget.js.map +1 -0
- package/dist/src/engine/capfd.d.ts +6 -0
- package/dist/src/engine/capfd.d.ts.map +1 -0
- package/dist/src/engine/capfd.js +21 -0
- package/dist/src/engine/capfd.js.map +1 -0
- package/dist/src/engine/caseout.d.ts +66 -0
- package/dist/src/engine/caseout.d.ts.map +1 -0
- package/dist/src/engine/caseout.js +107 -0
- package/dist/src/engine/caseout.js.map +1 -0
- package/dist/src/engine/context.d.ts +57 -0
- package/dist/src/engine/context.d.ts.map +1 -0
- package/dist/src/engine/context.js +212 -0
- package/dist/src/engine/context.js.map +1 -0
- package/dist/src/engine/envmap.d.ts +13 -0
- package/dist/src/engine/envmap.d.ts.map +1 -0
- package/dist/src/engine/envmap.js +372 -0
- package/dist/src/engine/envmap.js.map +1 -0
- package/dist/src/engine/extfiles.d.ts +7 -0
- package/dist/src/engine/extfiles.d.ts.map +1 -0
- package/dist/src/engine/extfiles.js +23 -0
- package/dist/src/engine/extfiles.js.map +1 -0
- package/dist/src/engine/keyroute.d.ts +21 -0
- package/dist/src/engine/keyroute.d.ts.map +1 -0
- package/dist/src/engine/keyroute.js +58 -0
- package/dist/src/engine/keyroute.js.map +1 -0
- package/dist/src/engine/lens/cache-group.d.ts +20 -0
- package/dist/src/engine/lens/cache-group.d.ts.map +1 -0
- package/dist/src/engine/lens/cache-group.js +31 -0
- package/dist/src/engine/lens/cache-group.js.map +1 -0
- package/dist/src/engine/lens/cache-run.d.ts +65 -0
- package/dist/src/engine/lens/cache-run.d.ts.map +1 -0
- package/dist/src/engine/lens/cache-run.js +159 -0
- package/dist/src/engine/lens/cache-run.js.map +1 -0
- package/dist/src/engine/lens/deadline.d.ts +54 -0
- package/dist/src/engine/lens/deadline.d.ts.map +1 -0
- package/dist/src/engine/lens/deadline.js +111 -0
- package/dist/src/engine/lens/deadline.js.map +1 -0
- package/dist/src/engine/lens/engine-tools.d.ts +13 -0
- package/dist/src/engine/lens/engine-tools.d.ts.map +1 -0
- package/dist/src/engine/lens/engine-tools.js +37 -0
- package/dist/src/engine/lens/engine-tools.js.map +1 -0
- package/dist/src/engine/lens/env.d.ts +23 -0
- package/dist/src/engine/lens/env.d.ts.map +1 -0
- package/dist/src/engine/lens/env.js +42 -0
- package/dist/src/engine/lens/env.js.map +1 -0
- package/dist/src/engine/lens/launch-plan.d.ts +63 -0
- package/dist/src/engine/lens/launch-plan.d.ts.map +1 -0
- package/dist/src/engine/lens/launch-plan.js +203 -0
- package/dist/src/engine/lens/launch-plan.js.map +1 -0
- package/dist/src/engine/lens/leaf-cache.d.ts +22 -0
- package/dist/src/engine/lens/leaf-cache.d.ts.map +1 -0
- package/dist/src/engine/lens/leaf-cache.js +115 -0
- package/dist/src/engine/lens/leaf-cache.js.map +1 -0
- package/dist/src/engine/lens/permits.d.ts +14 -0
- package/dist/src/engine/lens/permits.d.ts.map +1 -0
- package/dist/src/engine/lens/permits.js +18 -0
- package/dist/src/engine/lens/permits.js.map +1 -0
- package/dist/src/engine/lens/registry.d.ts +55 -0
- package/dist/src/engine/lens/registry.d.ts.map +1 -0
- package/dist/src/engine/lens/registry.js +140 -0
- package/dist/src/engine/lens/registry.js.map +1 -0
- package/dist/src/engine/lens/round-lens.d.ts +16 -0
- package/dist/src/engine/lens/round-lens.d.ts.map +1 -0
- package/dist/src/engine/lens/round-lens.js +46 -0
- package/dist/src/engine/lens/round-lens.js.map +1 -0
- package/dist/src/engine/lens/runlens.d.ts +103 -0
- package/dist/src/engine/lens/runlens.d.ts.map +1 -0
- package/dist/src/engine/lens/runlens.js +538 -0
- package/dist/src/engine/lens/runlens.js.map +1 -0
- package/dist/src/engine/lens/section.d.ts +19 -0
- package/dist/src/engine/lens/section.d.ts.map +1 -0
- package/dist/src/engine/lens/section.js +40 -0
- package/dist/src/engine/lens/section.js.map +1 -0
- package/dist/src/engine/lens/semaphore.d.ts +13 -0
- package/dist/src/engine/lens/semaphore.d.ts.map +1 -0
- package/dist/src/engine/lens/semaphore.js +44 -0
- package/dist/src/engine/lens/semaphore.js.map +1 -0
- package/dist/src/engine/lens/tool.d.ts +34 -0
- package/dist/src/engine/lens/tool.d.ts.map +1 -0
- package/dist/src/engine/lens/tool.js +345 -0
- package/dist/src/engine/lens/tool.js.map +1 -0
- package/dist/src/engine/lens/wave.d.ts +63 -0
- package/dist/src/engine/lens/wave.d.ts.map +1 -0
- package/dist/src/engine/lens/wave.js +95 -0
- package/dist/src/engine/lens/wave.js.map +1 -0
- package/dist/src/engine/livesessions.d.ts +21 -0
- package/dist/src/engine/livesessions.d.ts.map +1 -0
- package/dist/src/engine/livesessions.js +57 -0
- package/dist/src/engine/livesessions.js.map +1 -0
- package/dist/src/engine/livewire.d.ts +18 -0
- package/dist/src/engine/livewire.d.ts.map +1 -0
- package/dist/src/engine/livewire.js +31 -0
- package/dist/src/engine/livewire.js.map +1 -0
- package/dist/src/engine/openable.d.ts +37 -0
- package/dist/src/engine/openable.d.ts.map +1 -0
- package/dist/src/engine/openable.js +120 -0
- package/dist/src/engine/openable.js.map +1 -0
- package/dist/src/engine/pisdk.d.ts +226 -0
- package/dist/src/engine/pisdk.d.ts.map +1 -0
- package/dist/src/engine/pisdk.js +552 -0
- package/dist/src/engine/pisdk.js.map +1 -0
- package/dist/src/engine/port.d.ts +144 -0
- package/dist/src/engine/port.d.ts.map +1 -0
- package/dist/src/engine/port.js +16 -0
- package/dist/src/engine/port.js.map +1 -0
- package/dist/src/engine/prompts.d.ts +159 -0
- package/dist/src/engine/prompts.d.ts.map +1 -0
- package/dist/src/engine/prompts.js +339 -0
- package/dist/src/engine/prompts.js.map +1 -0
- package/dist/src/engine/render.d.ts +10 -0
- package/dist/src/engine/render.d.ts.map +1 -0
- package/dist/src/engine/render.js +23 -0
- package/dist/src/engine/render.js.map +1 -0
- package/dist/src/engine/reviewruntime.d.ts +13 -0
- package/dist/src/engine/reviewruntime.d.ts.map +1 -0
- package/dist/src/engine/reviewruntime.js +35 -0
- package/dist/src/engine/reviewruntime.js.map +1 -0
- package/dist/src/engine/reviewsdk.d.ts +75 -0
- package/dist/src/engine/reviewsdk.d.ts.map +1 -0
- package/dist/src/engine/reviewsdk.js +180 -0
- package/dist/src/engine/reviewsdk.js.map +1 -0
- package/dist/src/engine/round.d.ts +166 -0
- package/dist/src/engine/round.d.ts.map +1 -0
- package/dist/src/engine/round.js +543 -0
- package/dist/src/engine/round.js.map +1 -0
- package/dist/src/engine/roundagents.d.ts +17 -0
- package/dist/src/engine/roundagents.d.ts.map +1 -0
- package/dist/src/engine/roundagents.js +49 -0
- package/dist/src/engine/roundagents.js.map +1 -0
- package/dist/src/engine/roundevents.d.ts +110 -0
- package/dist/src/engine/roundevents.d.ts.map +1 -0
- package/dist/src/engine/roundevents.js +276 -0
- package/dist/src/engine/roundevents.js.map +1 -0
- package/dist/src/engine/roundsdk.d.ts +33 -0
- package/dist/src/engine/roundsdk.d.ts.map +1 -0
- package/dist/src/engine/roundsdk.js +76 -0
- package/dist/src/engine/roundsdk.js.map +1 -0
- package/dist/src/engine/sdkruntime.d.ts +23 -0
- package/dist/src/engine/sdkruntime.d.ts.map +1 -0
- package/dist/src/engine/sdkruntime.js +340 -0
- package/dist/src/engine/sdkruntime.js.map +1 -0
- package/dist/src/engine/select.d.ts +16 -0
- package/dist/src/engine/select.d.ts.map +1 -0
- package/dist/src/engine/select.js +32 -0
- package/dist/src/engine/select.js.map +1 -0
- package/dist/src/engine/sessionwrap.d.ts +4 -0
- package/dist/src/engine/sessionwrap.d.ts.map +1 -0
- package/dist/src/engine/sessionwrap.js +31 -0
- package/dist/src/engine/sessionwrap.js.map +1 -0
- package/dist/src/engine/toolfence.d.ts +90 -0
- package/dist/src/engine/toolfence.d.ts.map +1 -0
- package/dist/src/engine/toolfence.js +378 -0
- package/dist/src/engine/toolfence.js.map +1 -0
- package/dist/src/engine/tooluid.d.ts +128 -0
- package/dist/src/engine/tooluid.d.ts.map +1 -0
- package/dist/src/engine/tooluid.js +401 -0
- package/dist/src/engine/tooluid.js.map +1 -0
- package/dist/src/envstrip.d.ts +10 -0
- package/dist/src/envstrip.d.ts.map +1 -0
- package/dist/src/envstrip.js +15 -0
- package/dist/src/envstrip.js.map +1 -0
- package/dist/src/escalationprompt.d.ts +22 -0
- package/dist/src/escalationprompt.d.ts.map +1 -0
- package/dist/src/escalationprompt.js +54 -0
- package/dist/src/escalationprompt.js.map +1 -0
- package/dist/src/execfn.d.ts +29 -0
- package/dist/src/execfn.d.ts.map +1 -0
- package/dist/src/execfn.js +55 -0
- package/dist/src/execfn.js.map +1 -0
- package/dist/src/finding-chain.d.ts +40 -0
- package/dist/src/finding-chain.d.ts.map +1 -0
- package/dist/src/finding-chain.js +76 -0
- package/dist/src/finding-chain.js.map +1 -0
- package/dist/src/finding-row.d.ts +24 -0
- package/dist/src/finding-row.d.ts.map +1 -0
- package/dist/src/finding-row.js +26 -0
- package/dist/src/finding-row.js.map +1 -0
- package/dist/src/findingkey.d.ts +99 -0
- package/dist/src/findingkey.d.ts.map +1 -0
- package/dist/src/findingkey.js +210 -0
- package/dist/src/findingkey.js.map +1 -0
- package/dist/src/findingsrecord.d.ts +175 -0
- package/dist/src/findingsrecord.d.ts.map +1 -0
- package/dist/src/findingsrecord.js +410 -0
- package/dist/src/findingsrecord.js.map +1 -0
- package/dist/src/followup-mode.d.ts +4 -0
- package/dist/src/followup-mode.d.ts.map +1 -0
- package/dist/src/followup-mode.js +6 -0
- package/dist/src/followup-mode.js.map +1 -0
- package/dist/src/gate.d.ts +199 -0
- package/dist/src/gate.d.ts.map +1 -0
- package/dist/src/gate.js +315 -0
- package/dist/src/gate.js.map +1 -0
- package/dist/src/ghcred.d.ts +58 -0
- package/dist/src/ghcred.d.ts.map +1 -0
- package/dist/src/ghcred.js +139 -0
- package/dist/src/ghcred.js.map +1 -0
- package/dist/src/githubAuth.d.ts +32 -0
- package/dist/src/githubAuth.d.ts.map +1 -0
- package/dist/src/githubAuth.js +116 -0
- package/dist/src/githubAuth.js.map +1 -0
- package/dist/src/githubcore.d.ts +20 -0
- package/dist/src/githubcore.d.ts.map +1 -0
- package/dist/src/githubcore.js +20 -0
- package/dist/src/githubcore.js.map +1 -0
- package/dist/src/glob.d.ts +11 -0
- package/dist/src/glob.d.ts.map +1 -0
- package/dist/src/glob.js +69 -0
- package/dist/src/glob.js.map +1 -0
- package/dist/src/guards.d.ts +93 -0
- package/dist/src/guards.d.ts.map +1 -0
- package/dist/src/guards.js +306 -0
- package/dist/src/guards.js.map +1 -0
- package/dist/src/heartbeat.d.ts +29 -0
- package/dist/src/heartbeat.d.ts.map +1 -0
- package/dist/src/heartbeat.js +87 -0
- package/dist/src/heartbeat.js.map +1 -0
- package/dist/src/heuristics.d.ts +247 -0
- package/dist/src/heuristics.d.ts.map +1 -0
- package/dist/src/heuristics.js +927 -0
- package/dist/src/heuristics.js.map +1 -0
- package/dist/src/homes.d.ts +181 -0
- package/dist/src/homes.d.ts.map +1 -0
- package/dist/src/homes.js +242 -0
- package/dist/src/homes.js.map +1 -0
- package/dist/src/hunkheader.d.ts +11 -0
- package/dist/src/hunkheader.d.ts.map +1 -0
- package/dist/src/hunkheader.js +18 -0
- package/dist/src/hunkheader.js.map +1 -0
- package/dist/src/include.d.ts +435 -0
- package/dist/src/include.d.ts.map +1 -0
- package/dist/src/include.js +1692 -0
- package/dist/src/include.js.map +1 -0
- package/dist/src/launchvocab.d.ts +36 -0
- package/dist/src/launchvocab.d.ts.map +1 -0
- package/dist/src/launchvocab.js +59 -0
- package/dist/src/launchvocab.js.map +1 -0
- package/dist/src/ledgercarry.d.ts +24 -0
- package/dist/src/ledgercarry.d.ts.map +1 -0
- package/dist/src/ledgercarry.js +109 -0
- package/dist/src/ledgercarry.js.map +1 -0
- package/dist/src/lensids.d.ts +7 -0
- package/dist/src/lensids.d.ts.map +1 -0
- package/dist/src/lensids.js +53 -0
- package/dist/src/lensids.js.map +1 -0
- package/dist/src/lensplanning.d.ts +8 -0
- package/dist/src/lensplanning.d.ts.map +1 -0
- package/dist/src/lensplanning.js +126 -0
- package/dist/src/lensplanning.js.map +1 -0
- package/dist/src/lensresult.d.ts +94 -0
- package/dist/src/lensresult.d.ts.map +1 -0
- package/dist/src/lensresult.js +162 -0
- package/dist/src/lensresult.js.map +1 -0
- package/dist/src/lenstuning.d.ts +97 -0
- package/dist/src/lenstuning.d.ts.map +1 -0
- package/dist/src/lenstuning.js +326 -0
- package/dist/src/lenstuning.js.map +1 -0
- package/dist/src/lintconfig.d.ts +31 -0
- package/dist/src/lintconfig.d.ts.map +1 -0
- package/dist/src/lintconfig.js +543 -0
- package/dist/src/lintconfig.js.map +1 -0
- package/dist/src/live/events.d.ts +188 -0
- package/dist/src/live/events.d.ts.map +1 -0
- package/dist/src/live/events.js +136 -0
- package/dist/src/live/events.js.map +1 -0
- package/dist/src/live/writer.d.ts +54 -0
- package/dist/src/live/writer.d.ts.map +1 -0
- package/dist/src/live/writer.js +516 -0
- package/dist/src/live/writer.js.map +1 -0
- package/dist/src/localreview.d.ts +128 -0
- package/dist/src/localreview.d.ts.map +1 -0
- package/dist/src/localreview.js +426 -0
- package/dist/src/localreview.js.map +1 -0
- package/dist/src/loosenedtests.d.ts +13 -0
- package/dist/src/loosenedtests.d.ts.map +1 -0
- package/dist/src/loosenedtests.js +188 -0
- package/dist/src/loosenedtests.js.map +1 -0
- package/dist/src/memoryport.d.ts +264 -0
- package/dist/src/memoryport.d.ts.map +1 -0
- package/dist/src/memoryport.js +445 -0
- package/dist/src/memoryport.js.map +1 -0
- package/dist/src/migrations.d.ts +106 -0
- package/dist/src/migrations.d.ts.map +1 -0
- package/dist/src/migrations.js +373 -0
- package/dist/src/migrations.js.map +1 -0
- package/dist/src/oneshot.d.ts +12 -0
- package/dist/src/oneshot.d.ts.map +1 -0
- package/dist/src/oneshot.js +8 -0
- package/dist/src/oneshot.js.map +1 -0
- package/dist/src/packing.d.ts +11 -0
- package/dist/src/packing.d.ts.map +1 -0
- package/dist/src/packing.js +31 -0
- package/dist/src/packing.js.map +1 -0
- package/dist/src/passes.d.ts +16 -0
- package/dist/src/passes.d.ts.map +1 -0
- package/dist/src/passes.js +104 -0
- package/dist/src/passes.js.map +1 -0
- package/dist/src/passtasks.d.ts +68 -0
- package/dist/src/passtasks.d.ts.map +1 -0
- package/dist/src/passtasks.js +360 -0
- package/dist/src/passtasks.js.map +1 -0
- package/dist/src/pathinside.d.ts +5 -0
- package/dist/src/pathinside.d.ts.map +1 -0
- package/dist/src/pathinside.js +9 -0
- package/dist/src/pathinside.js.map +1 -0
- package/dist/src/pibuiltins.d.ts +36 -0
- package/dist/src/pibuiltins.d.ts.map +1 -0
- package/dist/src/pibuiltins.js +83 -0
- package/dist/src/pibuiltins.js.map +1 -0
- package/dist/src/plankey.d.ts +4 -0
- package/dist/src/plankey.d.ts.map +1 -0
- package/dist/src/plankey.js +10 -0
- package/dist/src/plankey.js.map +1 -0
- package/dist/src/planner.d.ts +375 -0
- package/dist/src/planner.d.ts.map +1 -0
- package/dist/src/planner.js +722 -0
- package/dist/src/planner.js.map +1 -0
- package/dist/src/plugin/args.d.ts +19 -0
- package/dist/src/plugin/args.d.ts.map +1 -0
- package/dist/src/plugin/args.js +107 -0
- package/dist/src/plugin/args.js.map +1 -0
- package/dist/src/plugin/index.d.ts +3 -0
- package/dist/src/plugin/index.d.ts.map +1 -0
- package/dist/src/plugin/index.js +14 -0
- package/dist/src/plugin/index.js.map +1 -0
- package/dist/src/plugin/login.d.ts +39 -0
- package/dist/src/plugin/login.d.ts.map +1 -0
- package/dist/src/plugin/login.js +73 -0
- package/dist/src/plugin/login.js.map +1 -0
- package/dist/src/plugin/review.d.ts +27 -0
- package/dist/src/plugin/review.d.ts.map +1 -0
- package/dist/src/plugin/review.js +298 -0
- package/dist/src/plugin/review.js.map +1 -0
- package/dist/src/plugin/runner.d.ts +6 -0
- package/dist/src/plugin/runner.d.ts.map +1 -0
- package/dist/src/plugin/runner.js +65 -0
- package/dist/src/plugin/runner.js.map +1 -0
- package/dist/src/plugin/scan.d.ts +79 -0
- package/dist/src/plugin/scan.d.ts.map +1 -0
- package/dist/src/plugin/scan.js +348 -0
- package/dist/src/plugin/scan.js.map +1 -0
- package/dist/src/plugin/session.d.ts +56 -0
- package/dist/src/plugin/session.d.ts.map +1 -0
- package/dist/src/plugin/session.js +2 -0
- package/dist/src/plugin/session.js.map +1 -0
- package/dist/src/plugin/setup.d.ts +19 -0
- package/dist/src/plugin/setup.d.ts.map +1 -0
- package/dist/src/plugin/setup.js +142 -0
- package/dist/src/plugin/setup.js.map +1 -0
- package/dist/src/plugin/versiongate.d.ts +26 -0
- package/dist/src/plugin/versiongate.d.ts.map +1 -0
- package/dist/src/plugin/versiongate.js +83 -0
- package/dist/src/plugin/versiongate.js.map +1 -0
- package/dist/src/postingkey.d.ts +8 -0
- package/dist/src/postingkey.d.ts.map +1 -0
- package/dist/src/postingkey.js +25 -0
- package/dist/src/postingkey.js.map +1 -0
- package/dist/src/postingpolicy.d.ts +108 -0
- package/dist/src/postingpolicy.d.ts.map +1 -0
- package/dist/src/postingpolicy.js +341 -0
- package/dist/src/postingpolicy.js.map +1 -0
- package/dist/src/pr-identity.d.ts +3 -0
- package/dist/src/pr-identity.d.ts.map +1 -0
- package/dist/src/pr-identity.js +9 -0
- package/dist/src/pr-identity.js.map +1 -0
- package/dist/src/prdelta.d.ts +181 -0
- package/dist/src/prdelta.d.ts.map +1 -0
- package/dist/src/prdelta.js +501 -0
- package/dist/src/prdelta.js.map +1 -0
- package/dist/src/pricing.d.ts +94 -0
- package/dist/src/pricing.d.ts.map +1 -0
- package/dist/src/pricing.js +209 -0
- package/dist/src/pricing.js.map +1 -0
- package/dist/src/priorfindings.d.ts +167 -0
- package/dist/src/priorfindings.d.ts.map +1 -0
- package/dist/src/priorfindings.js +445 -0
- package/dist/src/priorfindings.js.map +1 -0
- package/dist/src/priortasks.d.ts +31 -0
- package/dist/src/priortasks.d.ts.map +1 -0
- package/dist/src/priortasks.js +119 -0
- package/dist/src/priortasks.js.map +1 -0
- package/dist/src/profilelookup.d.ts +59 -0
- package/dist/src/profilelookup.d.ts.map +1 -0
- package/dist/src/profilelookup.js +98 -0
- package/dist/src/profilelookup.js.map +1 -0
- package/dist/src/profileoverrides.d.ts +91 -0
- package/dist/src/profileoverrides.d.ts.map +1 -0
- package/dist/src/profileoverrides.js +313 -0
- package/dist/src/profileoverrides.js.map +1 -0
- package/dist/src/profiles.d.ts +144 -0
- package/dist/src/profiles.d.ts.map +1 -0
- package/dist/src/profiles.js +604 -0
- package/dist/src/profiles.js.map +1 -0
- package/dist/src/promptdoc.d.ts +3 -0
- package/dist/src/promptdoc.d.ts.map +1 -0
- package/dist/src/promptdoc.js +18 -0
- package/dist/src/promptdoc.js.map +1 -0
- package/dist/src/prtarget.d.ts +70 -0
- package/dist/src/prtarget.d.ts.map +1 -0
- package/dist/src/prtarget.js +181 -0
- package/dist/src/prtarget.js.map +1 -0
- package/dist/src/recheckorder.d.ts +32 -0
- package/dist/src/recheckorder.d.ts.map +1 -0
- package/dist/src/recheckorder.js +5 -0
- package/dist/src/recheckorder.js.map +1 -0
- package/dist/src/reviewapi.d.ts +207 -0
- package/dist/src/reviewapi.d.ts.map +1 -0
- package/dist/src/reviewapi.js +939 -0
- package/dist/src/reviewapi.js.map +1 -0
- package/dist/src/reviewbotfindings.d.ts +33 -0
- package/dist/src/reviewbotfindings.d.ts.map +1 -0
- package/dist/src/reviewbotfindings.js +80 -0
- package/dist/src/reviewbotfindings.js.map +1 -0
- package/dist/src/reviewconfig.d.ts +741 -0
- package/dist/src/reviewconfig.d.ts.map +1 -0
- package/dist/src/reviewconfig.js +797 -0
- package/dist/src/reviewconfig.js.map +1 -0
- package/dist/src/reviewdelta.d.ts +51 -0
- package/dist/src/reviewdelta.d.ts.map +1 -0
- package/dist/src/reviewdelta.js +119 -0
- package/dist/src/reviewdelta.js.map +1 -0
- package/dist/src/reviewdest.d.ts +86 -0
- package/dist/src/reviewdest.d.ts.map +1 -0
- package/dist/src/reviewdest.js +142 -0
- package/dist/src/reviewdest.js.map +1 -0
- package/dist/src/reviewerstub.d.ts +12 -0
- package/dist/src/reviewerstub.d.ts.map +1 -0
- package/dist/src/reviewerstub.js +28 -0
- package/dist/src/reviewerstub.js.map +1 -0
- package/dist/src/reviewhost.d.ts +58 -0
- package/dist/src/reviewhost.d.ts.map +1 -0
- package/dist/src/reviewhost.js +135 -0
- package/dist/src/reviewhost.js.map +1 -0
- package/dist/src/reviewpost.d.ts +79 -0
- package/dist/src/reviewpost.d.ts.map +1 -0
- package/dist/src/reviewpost.js +233 -0
- package/dist/src/reviewpost.js.map +1 -0
- package/dist/src/reviewrender.d.ts +154 -0
- package/dist/src/reviewrender.d.ts.map +1 -0
- package/dist/src/reviewrender.js +480 -0
- package/dist/src/reviewrender.js.map +1 -0
- package/dist/src/reviewstate.d.ts +62 -0
- package/dist/src/reviewstate.d.ts.map +1 -0
- package/dist/src/reviewstate.js +167 -0
- package/dist/src/reviewstate.js.map +1 -0
- package/dist/src/reviewstore.d.ts +17 -0
- package/dist/src/reviewstore.d.ts.map +1 -0
- package/dist/src/reviewstore.js +105 -0
- package/dist/src/reviewstore.js.map +1 -0
- package/dist/src/round-progress.d.ts +120 -0
- package/dist/src/round-progress.d.ts.map +1 -0
- package/dist/src/round-progress.js +172 -0
- package/dist/src/round-progress.js.map +1 -0
- package/dist/src/round-stage-deadlines.d.ts +12 -0
- package/dist/src/round-stage-deadlines.d.ts.map +1 -0
- package/dist/src/round-stage-deadlines.js +10 -0
- package/dist/src/round-stage-deadlines.js.map +1 -0
- package/dist/src/session-command.d.ts +19 -0
- package/dist/src/session-command.d.ts.map +1 -0
- package/dist/src/session-command.js +20 -0
- package/dist/src/session-command.js.map +1 -0
- package/dist/src/settledread.d.ts +61 -0
- package/dist/src/settledread.d.ts.map +1 -0
- package/dist/src/settledread.js +135 -0
- package/dist/src/settledread.js.map +1 -0
- package/dist/src/severity.d.ts +167 -0
- package/dist/src/severity.d.ts.map +1 -0
- package/dist/src/severity.js +364 -0
- package/dist/src/severity.js.map +1 -0
- package/dist/src/severityrank.d.ts +8 -0
- package/dist/src/severityrank.d.ts.map +1 -0
- package/dist/src/severityrank.js +16 -0
- package/dist/src/severityrank.js.map +1 -0
- package/dist/src/standing-review.d.ts +45 -0
- package/dist/src/standing-review.d.ts.map +1 -0
- package/dist/src/standing-review.js +43 -0
- package/dist/src/standing-review.js.map +1 -0
- package/dist/src/storerows.d.ts +225 -0
- package/dist/src/storerows.d.ts.map +1 -0
- package/dist/src/storerows.js +7 -0
- package/dist/src/storerows.js.map +1 -0
- package/dist/src/styleguide.d.ts +25 -0
- package/dist/src/styleguide.d.ts.map +1 -0
- package/dist/src/styleguide.js +272 -0
- package/dist/src/styleguide.js.map +1 -0
- package/dist/src/sweepstatus.d.ts +42 -0
- package/dist/src/sweepstatus.d.ts.map +1 -0
- package/dist/src/sweepstatus.js +97 -0
- package/dist/src/sweepstatus.js.map +1 -0
- package/dist/src/templates.d.ts +243 -0
- package/dist/src/templates.d.ts.map +1 -0
- package/dist/src/templates.js +847 -0
- package/dist/src/templates.js.map +1 -0
- package/dist/src/types.d.ts +639 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +90 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/unifieddiff.d.ts +22 -0
- package/dist/src/unifieddiff.d.ts.map +1 -0
- package/dist/src/unifieddiff.js +176 -0
- package/dist/src/unifieddiff.js.map +1 -0
- package/dist/src/yieldloop.d.ts +2 -0
- package/dist/src/yieldloop.d.ts.map +1 -0
- package/dist/src/yieldloop.js +14 -0
- package/dist/src/yieldloop.js.map +1 -0
- package/package.json +60 -4
|
@@ -0,0 +1,3058 @@
|
|
|
1
|
+
// bash-guard — pi extension enforcing the sessions' no-exec policy at the
|
|
2
|
+
// tool-call boundary. Review sessions read code; they never run it. The guard
|
|
3
|
+
// inspects every bash tool call and blocks any segment whose program is not on
|
|
4
|
+
// the read-only allowlist, plus mutating `gh` and `git` verbs (sanctioned mutations go
|
|
5
|
+
// through the repo's own scripts/*.sh surface, which IS allowed for the
|
|
6
|
+
// specific script basenames below — approve-pr.sh, mint-token.sh (read-only
|
|
7
|
+
// gh needs it) and the pr-*/post-review/reply-thread helpers; deploy.sh
|
|
8
|
+
// stays unreachable).
|
|
9
|
+
//
|
|
10
|
+
// The checker is pure (checkBashCommand) so tests need no pi. The pi hookup
|
|
11
|
+
// (`pi.on("tool_call", …)` returning {block, reason}) is a VERIFIED capability
|
|
12
|
+
// per the pi control-surface findings (§5), and its `toolName` and `input`
|
|
13
|
+
// fields are seen in production at pi 1.3.0 (a vetoed call's exec-policy reason
|
|
14
|
+
// reached the model).
|
|
15
|
+
//
|
|
16
|
+
// This guard is ONE LAYER of the exec boundary, not the whole of it. It vets
|
|
17
|
+
// command strings, and a command guard never sees a FILE the session writes —
|
|
18
|
+
// which is the decisive ingredient in every bypass of a string-only guard. So:
|
|
19
|
+
//
|
|
20
|
+
// · it also vets the WRITE tools, on one question — is this path a
|
|
21
|
+
// PROTECTED write target (`isProtectedWriteTarget`)? That is git's
|
|
22
|
+
// configuration surface (`isGitConfigSurface` — the repo config scope,
|
|
23
|
+
// where a plain `git diff` executes a program named by
|
|
24
|
+
// `<clone>/.git/config` with no suspicious token in the command at all),
|
|
25
|
+
// this system's own policy surface (`isPolicySurface` — config.toml, the
|
|
26
|
+
// sanctioned scripts, the compiled guard, the seeds), the memory-scope
|
|
27
|
+
// files a session's own recall reads (`isMemoryScopeSurface` — the recall
|
|
28
|
+
// hint and the retired list), and the wake-delivery files
|
|
29
|
+
// (`isDeliverySurface` — the inject dirs and the bridge's `.done/`);
|
|
30
|
+
// · the global/XDG/system scopes are closed OUTSIDE this file, by
|
|
31
|
+
// `sessionGitConfigPins` in src/sessions.ts, which pins where git looks for
|
|
32
|
+
// configuration at spawn. The guard's job there is only to stop a caller
|
|
33
|
+
// RE-POINTING the pin (COMMAND_INJECTING_ENV).
|
|
34
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
35
|
+
import { posix } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
/**
|
|
38
|
+
* Read-only programs a review session may run directly.
|
|
39
|
+
*
|
|
40
|
+
* **`awk` is deliberately ABSENT, and must not be re-added.** No regex gating
|
|
41
|
+
* of awk is sound: it is a programming language in which `system(x)`,
|
|
42
|
+
* `x | getline`, `print | x` and `print > x` are all first-class and every one
|
|
43
|
+
* takes an ARBITRARY EXPRESSION, so a bare identifier holding the command
|
|
44
|
+
* defeats any rule keyed on the token after the operator. VERIFIED executing:
|
|
45
|
+
*
|
|
46
|
+
* awk 'BEGIN{c="sh"; print "<cmd>" | c}' → ran <cmd>
|
|
47
|
+
* awk 'BEGIN{f="<path>"; print "x" > f}' → wrote <path>
|
|
48
|
+
* awk 'BEGIN{f=".git/config"; printf … >> f}' → then a plain `git diff` ran <cmd>
|
|
49
|
+
*
|
|
50
|
+
* The first is arbitrary execution with no config file touched, so the
|
|
51
|
+
* round-boundary integrity check cannot see it either.
|
|
52
|
+
*
|
|
53
|
+
* The cost is close to zero: `awk` appears in no prompt, no agent and no
|
|
54
|
+
* session-sanctioned script. A reviewer keeps `rg`, `grep`, `jq`, `cut`,
|
|
55
|
+
* `sort`, `uniq`, `head`, `tail`, `wc`, `nl`, `tr` and `sed -n`; `gawk`,
|
|
56
|
+
* `mawk` and `nawk` are off ALLOWED too.
|
|
57
|
+
*
|
|
58
|
+
* `sed` stays, on a narrower argument: its script language is small and
|
|
59
|
+
* fixed — its file/exec verbs are the `e`/`w`/`W`/`r`/`R` commands and the
|
|
60
|
+
* `e`/`w` flags of `s` — so `checkSedInvocation` PARSES every invocation
|
|
61
|
+
* (options, addresses, any `s` delimiter) and refuses anything it cannot
|
|
62
|
+
* classify. awk's language takes an expression anywhere; sed's does not.
|
|
63
|
+
*
|
|
64
|
+
* Exported in ORDER because the sentence every built pass reads ("Allowed
|
|
65
|
+
* commands: …", `packages/pi-plugin/src/include.ts`) is rendered from this array: a model told a
|
|
66
|
+
* tool exists and then refused reads the refusal as a malfunction, so the
|
|
67
|
+
* enforcing half is the only author of the advertised half;
|
|
68
|
+
* `tests/fences.test.ts` holds the advertised set equal to this array, runs
|
|
69
|
+
* every advertised program through `checkBashCommand` (from source), and
|
|
70
|
+
* separately checks the rendered sentence against `packages/pi-plugin/dist/src/include.js`.
|
|
71
|
+
*/
|
|
72
|
+
export const ALLOWED_PROGRAMS = [
|
|
73
|
+
"git",
|
|
74
|
+
"rg",
|
|
75
|
+
"grep",
|
|
76
|
+
"cat",
|
|
77
|
+
"ls",
|
|
78
|
+
"find",
|
|
79
|
+
"head",
|
|
80
|
+
"tail",
|
|
81
|
+
"wc",
|
|
82
|
+
"jq",
|
|
83
|
+
"sed",
|
|
84
|
+
"sort",
|
|
85
|
+
"uniq",
|
|
86
|
+
"diff",
|
|
87
|
+
"gh",
|
|
88
|
+
"echo",
|
|
89
|
+
"printf",
|
|
90
|
+
"cut",
|
|
91
|
+
"tr",
|
|
92
|
+
"xargs",
|
|
93
|
+
"dirname",
|
|
94
|
+
"basename",
|
|
95
|
+
"test",
|
|
96
|
+
"true",
|
|
97
|
+
"false",
|
|
98
|
+
"date",
|
|
99
|
+
"nl",
|
|
100
|
+
"stat",
|
|
101
|
+
];
|
|
102
|
+
const ALLOWED = new Set(ALLOWED_PROGRAMS);
|
|
103
|
+
/** Programs whose whole point is to run code the caller supplies. They are off
|
|
104
|
+
* ALLOWED, so the generic refusal already covers them — this exists only so the
|
|
105
|
+
* reason names the replacement instead of leaving the model to guess and retry.
|
|
106
|
+
* (`node`/`python`/… are handled by AGENT_SPAWNERS with their own reason.) */
|
|
107
|
+
const INTERPRETER_REASONS = {
|
|
108
|
+
awk: "exec policy: 'awk' is a programming language that can run commands (system(), `print | cmd`, `cmd | getline`) and write files, including through a variable no guard can read — use rg/grep/jq/cut/sort/sed -n for text",
|
|
109
|
+
gawk: "exec policy: 'gawk' is awk — see the awk rule; use rg/grep/jq/cut/sort/sed -n for text",
|
|
110
|
+
mawk: "exec policy: 'mawk' is awk — see the awk rule; use rg/grep/jq/cut/sort/sed -n for text",
|
|
111
|
+
nawk: "exec policy: 'nawk' is awk — see the awk rule; use rg/grep/jq/cut/sort/sed -n for text",
|
|
112
|
+
perl: "exec policy: 'perl' runs arbitrary code — use rg/grep/jq/cut/sort/sed -n for text",
|
|
113
|
+
ruby: "exec policy: 'ruby' runs arbitrary code — use rg/grep/jq/cut/sort/sed -n for text",
|
|
114
|
+
php: "exec policy: 'php' runs arbitrary code — use rg/grep/jq/cut/sort/sed -n for text",
|
|
115
|
+
};
|
|
116
|
+
/** Sanctioned sandbox scripts — the controlled surface for routine GitHub
|
|
117
|
+
* reads and gate-mirrored posting. mint-token.sh is sanctioned because the
|
|
118
|
+
* templates allow raw READ-ONLY `gh`, which needs `GH_TOKEN="$(…)"`.
|
|
119
|
+
* deploy.sh stays unreachable. */
|
|
120
|
+
const SANCTIONED_SCRIPTS = new Set([
|
|
121
|
+
"approve-pr.sh",
|
|
122
|
+
"mint-token.sh",
|
|
123
|
+
"pr-context.sh",
|
|
124
|
+
"pr-checks.sh",
|
|
125
|
+
// Mandated by all three round templates. Read-only by construction — GETs
|
|
126
|
+
// only, no method override, no body — and its two delegates, pr-threads.sh
|
|
127
|
+
// and pr-checks.sh, are sanctioned too. `tests/fences.test.ts` derives this
|
|
128
|
+
// set from the prompts, so the exec policy and the prompt contract cannot
|
|
129
|
+
// disagree.
|
|
130
|
+
"pr-self.sh",
|
|
131
|
+
"pr-threads.sh",
|
|
132
|
+
"post-review.sh",
|
|
133
|
+
"reply-thread.sh",
|
|
134
|
+
// `[review] render_outputs` (packages/pi-plugin/src/reviewrender.ts): reads the round's
|
|
135
|
+
// findings record, writes the two pinned scratch files under state/. Posts
|
|
136
|
+
// nothing; posting still goes through post-review.sh / approve-pr.sh.
|
|
137
|
+
"render-review.sh",
|
|
138
|
+
// The round templates stage the verdict at verdict/<name>.tmp and publish
|
|
139
|
+
// it with this — one rename inside verdict/, so the daemon never reads a
|
|
140
|
+
// half-written verdict. `mv` itself stays off ALLOWED: the script takes only
|
|
141
|
+
// the two verdict-file name shapes, writes nothing outside verdict/, reads
|
|
142
|
+
// no config and posts nothing. Its `--from-record` mode also READS the
|
|
143
|
+
// round's own findings record to build the verdict from fields the session
|
|
144
|
+
// already wrote — still no other write.
|
|
145
|
+
"commit-verdict.sh",
|
|
146
|
+
]);
|
|
147
|
+
/** Agent-spawning programs. They are off ALLOWED already; this set exists so
|
|
148
|
+
* the refusal tells the model what to do instead, and so the single-shepherd
|
|
149
|
+
* invariant has a rule that greps.
|
|
150
|
+
* The PR's main pi is the only scheduler of agents, and the lens tool (`lens` /
|
|
151
|
+
* `lens_wave`) is its only launcher. */
|
|
152
|
+
const AGENT_SPAWNERS = new Set([
|
|
153
|
+
"pi",
|
|
154
|
+
"claude",
|
|
155
|
+
"codex",
|
|
156
|
+
"node",
|
|
157
|
+
"npm",
|
|
158
|
+
"npx",
|
|
159
|
+
"pnpm",
|
|
160
|
+
"yarn",
|
|
161
|
+
"bun",
|
|
162
|
+
"bunx",
|
|
163
|
+
"deno",
|
|
164
|
+
"tsx",
|
|
165
|
+
"ts-node",
|
|
166
|
+
]);
|
|
167
|
+
const AGENT_SPAWN_REASON = "exec policy: never spawn an agent or a runtime from bash — the lens tool (`lens` / `lens_wave`) is the only launcher (single-shepherd invariant)";
|
|
168
|
+
/** Relative under scripts/ only: no `..`, no absolute paths, exactly one
|
|
169
|
+
* path segment after scripts/. */
|
|
170
|
+
const SCRIPT_PATH_RE = /^(?:\.\/)?scripts\/([A-Za-z0-9._-]+\.sh)$/;
|
|
171
|
+
function block(reason) {
|
|
172
|
+
return { allowed: false, reason };
|
|
173
|
+
}
|
|
174
|
+
function sanctionedScript(token, cwd, policy) {
|
|
175
|
+
const m = SCRIPT_PATH_RE.exec(token);
|
|
176
|
+
if (!m)
|
|
177
|
+
return { sanctioned: false };
|
|
178
|
+
return {
|
|
179
|
+
sanctioned: !policy.replay &&
|
|
180
|
+
!(policy.noPost && POST_SCRIPTS.has(m[1])) &&
|
|
181
|
+
trustedScriptCwd(cwd, policy) &&
|
|
182
|
+
SANCTIONED_SCRIPTS.has(m[1]),
|
|
183
|
+
name: m[1],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** The sanctioned scripts that post to GitHub: review, approval and thread reply. */
|
|
187
|
+
const POST_SCRIPTS = new Set([
|
|
188
|
+
"post-review.sh",
|
|
189
|
+
"approve-pr.sh",
|
|
190
|
+
"reply-thread.sh",
|
|
191
|
+
]);
|
|
192
|
+
const TAKEOVER_NO_POST = "exec policy: a takeover posts nothing — the operator works in the session with read tools; a review, an approval or a reply goes out from a round";
|
|
193
|
+
/** Why a script is refused: the replay's or the takeover's own reason where one
|
|
194
|
+
* applies, else `fallback`. */
|
|
195
|
+
function notSanctioned(policy, name, fallback) {
|
|
196
|
+
if (policy.replay)
|
|
197
|
+
return REPLAY_NO_SCRIPTS;
|
|
198
|
+
if (policy.noPost && name !== undefined && POST_SCRIPTS.has(name))
|
|
199
|
+
return TAKEOVER_NO_POST;
|
|
200
|
+
return fallback;
|
|
201
|
+
}
|
|
202
|
+
/** Only the daemon-selected checkout owns the sanctioned script bytes. */
|
|
203
|
+
function trustedScriptCwd(cwd, policy) {
|
|
204
|
+
if (cwd === null)
|
|
205
|
+
return false;
|
|
206
|
+
try {
|
|
207
|
+
return realpathSync.native(cwd) === realpathSync.native(policy.sandboxRoot);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** A changed shell directory is not inferred from a reviewed path. Only an
|
|
214
|
+
* explicit cd to the daemon's absolute root preserves script authorization;
|
|
215
|
+
* every other cd conservatively invalidates it for this whole command. */
|
|
216
|
+
function scriptCwdForCommand(cmd, cwd, policy) {
|
|
217
|
+
for (const segment of splitShellSegments(cmd)) {
|
|
218
|
+
const words = shellWords(segment).words;
|
|
219
|
+
let at = 0;
|
|
220
|
+
while (words[at] === "env" || ASSIGNMENT_RE.test(words[at] ?? ""))
|
|
221
|
+
at++;
|
|
222
|
+
const program = (words[at] ?? "").replace(/^\(+/, "");
|
|
223
|
+
if (basenameOf(program) !== "cd")
|
|
224
|
+
continue;
|
|
225
|
+
const args = words.slice(at + 1).filter((word) => word !== "--");
|
|
226
|
+
if (args.length !== 1 || !args[0].startsWith("/") || !trustedScriptCwd(args[0], policy))
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
return cwd;
|
|
230
|
+
}
|
|
231
|
+
const REPLAY_NO_SCRIPTS = "exec policy: a regression replay session runs no sandbox script — it reads the tree and writes one findings file";
|
|
232
|
+
/**
|
|
233
|
+
* `gh` is READ-ONLY, by ALLOWLIST: only the read verbs named here are allowed,
|
|
234
|
+
* plus `gh api` without a mutating method or body flag. A blocklist would
|
|
235
|
+
* leave verbs like `gh pr review <n> --approve` open — a direct approval
|
|
236
|
+
* around the mutation gate, the CI gate and `dry_run`, using the installation
|
|
237
|
+
* token the session can mint from the sanctioned `scripts/mint-token.sh`
|
|
238
|
+
* (which carries `pull_requests: write`) — and would need extending every time
|
|
239
|
+
* `gh` grows a verb. Every posting mutation goes through the sanctioned
|
|
240
|
+
* scripts, which mirror the gate's guards.
|
|
241
|
+
*/
|
|
242
|
+
const GH_READONLY_VERBS = {
|
|
243
|
+
pr: new Set(["view", "list", "diff", "checks", "status"]),
|
|
244
|
+
issue: new Set(["view", "list", "status"]),
|
|
245
|
+
run: new Set(["view", "list"]),
|
|
246
|
+
// `gh workflow run` DISPATCHES a workflow — CI execution the reviewer must
|
|
247
|
+
// never start (scoping rule 2: the reviewer reads CI results, never runs it).
|
|
248
|
+
workflow: new Set(["view", "list"]),
|
|
249
|
+
release: new Set(["view", "list"]),
|
|
250
|
+
repo: new Set(["view"]),
|
|
251
|
+
label: new Set(["list"]),
|
|
252
|
+
cache: new Set(["list"]),
|
|
253
|
+
auth: new Set(["status"]),
|
|
254
|
+
};
|
|
255
|
+
/** `gh` namespaces that are read-only whatever the verb. */
|
|
256
|
+
const GH_READONLY_NAMESPACES = new Set([
|
|
257
|
+
"search",
|
|
258
|
+
"status",
|
|
259
|
+
"version",
|
|
260
|
+
"help",
|
|
261
|
+
]);
|
|
262
|
+
/**
|
|
263
|
+
* `gh api` flags that carry a REQUEST BODY, and so promote gh's default GET to
|
|
264
|
+
* POST. Enumerated against `gh api --help` on gh 2.93.0 and confirmed
|
|
265
|
+
* empirically against a loopback server:
|
|
266
|
+
*
|
|
267
|
+
* gh api <path> --input f → POST gh api <path> → GET
|
|
268
|
+
* gh api <path> --input=f → POST gh api <path> --method GET … → GET
|
|
269
|
+
*
|
|
270
|
+
* `--input` matters most: a session can write `{"event":"APPROVE"}` with its
|
|
271
|
+
* ordinary write tool, mint the token from the sanctioned
|
|
272
|
+
* `scripts/mint-token.sh`, and `gh api .../pulls/<n>/reviews --input` it — a
|
|
273
|
+
* complete approval channel.
|
|
274
|
+
*
|
|
275
|
+
* NOTE — `-i` is deliberately NOT a body flag. In gh it is `--include` (print
|
|
276
|
+
* response status line and headers), a pure READ flag; `--input` has no short
|
|
277
|
+
* form, and no other short flag carries a body. Verified in `gh api --help`
|
|
278
|
+
* and by probe: `gh api <path> -i` → GET.
|
|
279
|
+
*/
|
|
280
|
+
const GH_API_BODY_FLAGS = new Set([
|
|
281
|
+
"--input",
|
|
282
|
+
"-f",
|
|
283
|
+
"-F",
|
|
284
|
+
"--field",
|
|
285
|
+
"--raw-field",
|
|
286
|
+
]);
|
|
287
|
+
const GH_API_BODY_FLAG_ATTACHED = /^(--input|--field|--raw-field)=/;
|
|
288
|
+
/**
|
|
289
|
+
* The SHORT body flags, structurally rather than by spelling.
|
|
290
|
+
*
|
|
291
|
+
* The set above is complete by flag NAME, but a name is not a form:
|
|
292
|
+
* `-fevent=APPROVE` attaches the value, and `-if` clusters the boolean `-i` in
|
|
293
|
+
* front of `-f`. Both send POST, verified on gh 2.93.0:
|
|
294
|
+
*
|
|
295
|
+
* gh api <path> -fevent=APPROVE → POST
|
|
296
|
+
* gh api <path> -Fevent=APPROVE → POST
|
|
297
|
+
* gh api <path> -if event=APPROVE → POST
|
|
298
|
+
* gh api <path> -i → GET
|
|
299
|
+
*
|
|
300
|
+
* So the rule is a shape, not more literals: any single-dash cluster
|
|
301
|
+
* containing `f` or `F` carries a body;
|
|
302
|
+
* `-i` alone contains neither, so `--include` keeps working. `-X`/`-XPOST`
|
|
303
|
+
* contain neither either, and are handled by the explicit-method branch.
|
|
304
|
+
*/
|
|
305
|
+
const GH_API_SHORT_BODY_CLUSTER = /^-[A-Za-z]*[fF]/;
|
|
306
|
+
/**
|
|
307
|
+
* What method will this `gh api` invocation actually send?
|
|
308
|
+
*
|
|
309
|
+
* An explicit `-X`/`--method` wins, otherwise a body flag promotes GET to
|
|
310
|
+
* POST, otherwise it is gh's default GET. Only GET (and HEAD) is allowed.
|
|
311
|
+
*/
|
|
312
|
+
function ghApiEffectiveMethod(args) {
|
|
313
|
+
let explicit = null;
|
|
314
|
+
let body = false;
|
|
315
|
+
for (let j = 0; j < args.length; j++) {
|
|
316
|
+
const a = args[j];
|
|
317
|
+
if (a === "--")
|
|
318
|
+
break;
|
|
319
|
+
if (a === "--method") {
|
|
320
|
+
explicit = (args[j + 1] ?? "").toUpperCase();
|
|
321
|
+
j++;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const attached = /^--method=(.*)$/.exec(a);
|
|
325
|
+
if (attached) {
|
|
326
|
+
explicit = attached[1].toUpperCase();
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (GH_API_BODY_FLAGS.has(a) || GH_API_BODY_FLAG_ATTACHED.test(a))
|
|
330
|
+
body = true;
|
|
331
|
+
if (/^--(?:header|jq|template|preview|hostname|cache|input|field|raw-field)$/.test(a)) {
|
|
332
|
+
j++; // a value, never a flag
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (/^-[A-Za-z]/.test(a) && !a.startsWith("--")) {
|
|
336
|
+
// A short cluster: switches, then at most one flag that takes the rest of
|
|
337
|
+
// the word (or the next word) as its value.
|
|
338
|
+
if (GH_API_SHORT_BODY_CLUSTER.test(a))
|
|
339
|
+
body = true;
|
|
340
|
+
for (let k = 1; k < a.length; k++) {
|
|
341
|
+
const ch = a[k];
|
|
342
|
+
if (!"XHqtpfF".includes(ch))
|
|
343
|
+
continue;
|
|
344
|
+
const rest = a.slice(k + 1);
|
|
345
|
+
if (ch === "X")
|
|
346
|
+
explicit = (rest || (args[j + 1] ?? "")).toUpperCase();
|
|
347
|
+
if (rest === "")
|
|
348
|
+
j++;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (explicit !== null)
|
|
354
|
+
return explicit || "GET";
|
|
355
|
+
return body ? "POST" : "GET";
|
|
356
|
+
}
|
|
357
|
+
function checkGh(args) {
|
|
358
|
+
// Step over leading `-`-prefixed tokens to find the namespace. NOTE: only the
|
|
359
|
+
// token, never a value — gh's own global flags (`--version`, `--help`) take
|
|
360
|
+
// none, and every `--repo`-style flag belongs to a subcommand and follows it.
|
|
361
|
+
// So `gh --repo o/n pr view` lands on `o/n`, which is not a namespace and is
|
|
362
|
+
// REFUSED. That fails closed, which is correct, and the reason names `o/n`
|
|
363
|
+
// rather than `pr`.
|
|
364
|
+
let i = 0;
|
|
365
|
+
while (i < args.length && args[i].startsWith("-"))
|
|
366
|
+
i++;
|
|
367
|
+
const ns = args[i];
|
|
368
|
+
if (ns === undefined)
|
|
369
|
+
return { allowed: true }; // bare `gh` prints help
|
|
370
|
+
if (ns === "api") {
|
|
371
|
+
const method = ghApiEffectiveMethod(args.slice(i + 1));
|
|
372
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
373
|
+
return block(`exec policy: this 'gh api' sends ${method}, not GET — every GitHub write goes through the ONE mutation gate (scripts/*.sh), never raw gh`);
|
|
374
|
+
}
|
|
375
|
+
return { allowed: true };
|
|
376
|
+
}
|
|
377
|
+
if (GH_READONLY_NAMESPACES.has(ns))
|
|
378
|
+
return { allowed: true };
|
|
379
|
+
const verbs = GH_READONLY_VERBS[ns];
|
|
380
|
+
if (verbs === undefined) {
|
|
381
|
+
return block(`exec policy: 'gh ${ns}' is not a sanctioned read-only gh command`);
|
|
382
|
+
}
|
|
383
|
+
// Skip flags between the namespace and its verb (`gh pr --help`).
|
|
384
|
+
let k = i + 1;
|
|
385
|
+
while (k < args.length && args[k].startsWith("-"))
|
|
386
|
+
k++;
|
|
387
|
+
const verb = args[k];
|
|
388
|
+
if (verb === undefined)
|
|
389
|
+
return { allowed: true }; // `gh pr` alone prints help
|
|
390
|
+
if (verbs.has(verb))
|
|
391
|
+
return { allowed: true };
|
|
392
|
+
return block(`exec policy: 'gh ${ns} ${verb}' is a mutating command — every GitHub write goes through the sanctioned scripts/*.sh, never raw gh`);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* `git` is READ-ONLY too, and — like `gh` — by ALLOWLIST, which keeps `git
|
|
396
|
+
* push` (a GitHub write around the one gate) and the discard family
|
|
397
|
+
* (`reset --hard`, `checkout --`, `clean -fdx`, `stash`) out. A blocklist of
|
|
398
|
+
* write verbs does not hold:
|
|
399
|
+
*
|
|
400
|
+
* 1. It is never closed: `send-pack` (the plumbing for `push`), `bundle`,
|
|
401
|
+
* `archive`, `svn`, `p4`, `daemon`, `fast-export`, `symbolic-ref`,
|
|
402
|
+
* `reflog`, `difftool`, `update-server-info`, `branch -D` / `tag -d`…
|
|
403
|
+
* 2. It keys on the verb, and `-c alias.x='!<shell>' x` supplies a NEW verb
|
|
404
|
+
* in one token. VERIFIED executing.
|
|
405
|
+
* 3. `git -C /some/repo <alias>` runs an alias out of that repo's own
|
|
406
|
+
* config. Under an allowlist the alias name is simply not a sanctioned
|
|
407
|
+
* read verb, so the whole class dies without enumerating it.
|
|
408
|
+
*
|
|
409
|
+
* A blocked read verb is a loud, recoverable refusal naming what to do; an
|
|
410
|
+
* unblocked write verb is an ungated mutation. The failure modes are not
|
|
411
|
+
* symmetric.
|
|
412
|
+
*/
|
|
413
|
+
const GIT_READONLY_VERBS = new Set([
|
|
414
|
+
"log",
|
|
415
|
+
"show",
|
|
416
|
+
"diff",
|
|
417
|
+
"diff-tree",
|
|
418
|
+
"diff-files",
|
|
419
|
+
"diff-index",
|
|
420
|
+
"status",
|
|
421
|
+
"blame",
|
|
422
|
+
"annotate",
|
|
423
|
+
"cat-file",
|
|
424
|
+
"rev-parse",
|
|
425
|
+
"rev-list",
|
|
426
|
+
"ls-files",
|
|
427
|
+
"ls-tree",
|
|
428
|
+
"ls-remote",
|
|
429
|
+
"grep",
|
|
430
|
+
"describe",
|
|
431
|
+
"shortlog",
|
|
432
|
+
"merge-base",
|
|
433
|
+
"name-rev",
|
|
434
|
+
"whatchanged",
|
|
435
|
+
"show-ref",
|
|
436
|
+
"show-branch",
|
|
437
|
+
"check-ignore",
|
|
438
|
+
"check-attr",
|
|
439
|
+
"count-objects",
|
|
440
|
+
"verify-commit",
|
|
441
|
+
"verify-tag",
|
|
442
|
+
"version",
|
|
443
|
+
// `range-diff` is the natural tool for a force-push re-review (what changed
|
|
444
|
+
// between the old and new versions of a series); `for-each-ref` is the
|
|
445
|
+
// scriptable ref read.
|
|
446
|
+
"range-diff",
|
|
447
|
+
"for-each-ref",
|
|
448
|
+
// A read of the remote. It cannot mutate GitHub, and the daemon fetches the
|
|
449
|
+
// base into every worktree already — but a session occasionally needs a ref
|
|
450
|
+
// the round did not anticipate, and refusing it would wedge that review.
|
|
451
|
+
"fetch",
|
|
452
|
+
]);
|
|
453
|
+
/**
|
|
454
|
+
* Namespaces whose read-only sub-form is allowed and whose write forms are not.
|
|
455
|
+
* `branch` and `tag` list by default but delete with a flag; `worktree`,
|
|
456
|
+
* `remote` and `config` all carry both.
|
|
457
|
+
*/
|
|
458
|
+
const GIT_READONLY_SUBVERBS = {
|
|
459
|
+
worktree: new Set(["list"]),
|
|
460
|
+
remote: new Set(["-v", "--verbose", "show", "get-url"]),
|
|
461
|
+
config: new Set(["--get", "--get-all", "--get-regexp", "--list", "-l"]),
|
|
462
|
+
branch: new Set([
|
|
463
|
+
"--list",
|
|
464
|
+
"-l",
|
|
465
|
+
"--contains",
|
|
466
|
+
"--merged",
|
|
467
|
+
"--no-merged",
|
|
468
|
+
"--show-current",
|
|
469
|
+
"-a",
|
|
470
|
+
"-r",
|
|
471
|
+
"-v",
|
|
472
|
+
"-vv",
|
|
473
|
+
"--all",
|
|
474
|
+
"--remotes",
|
|
475
|
+
]),
|
|
476
|
+
tag: new Set(["--list", "-l", "--contains", "--points-at", "--merged"]),
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* Options that let git run a command of the caller's choosing.
|
|
480
|
+
*
|
|
481
|
+
* The family is **anything that changes WHICH CONFIG GIT READS, or names a
|
|
482
|
+
* program directly** — three members:
|
|
483
|
+
*
|
|
484
|
+
* 1. INJECT config inline — `-c key=value`, `--config-env=key=ENVVAR`.
|
|
485
|
+
* 2. NAME a program — `--upload-pack`, `--receive-pack`, `--exec`,
|
|
486
|
+
* `--exec-path` (where git looks for its own subcommand binaries).
|
|
487
|
+
* 3. RELOCATE the config git reads — at ANY of its three scopes:
|
|
488
|
+
* · repo — `--git-dir`, `--work-tree`, `GIT_DIR`, `GIT_WORK_TREE`
|
|
489
|
+
* · global — `GIT_CONFIG_GLOBAL`, and the search path it falls back
|
|
490
|
+
* to: `HOME` (`$HOME/.gitconfig`) and `XDG_CONFIG_HOME`
|
|
491
|
+
* (`$XDG_CONFIG_HOME/git/config`)
|
|
492
|
+
* · system — `GIT_CONFIG_SYSTEM`
|
|
493
|
+
* The env half lives in COMMAND_INJECTING_ENV below.
|
|
494
|
+
*
|
|
495
|
+
* All three end in the same place, because git executes config-supplied shell
|
|
496
|
+
* from `alias.*` (`!cmd`), `core.pager`, `credential.helper` (`!cmd`),
|
|
497
|
+
* `core.sshCommand`, `diff.external`, `diff.*.textconv` and `filter.*.clean`.
|
|
498
|
+
*
|
|
499
|
+
* VERIFIED by execution, not inferred:
|
|
500
|
+
*
|
|
501
|
+
* git -c "alias.probe=!<cmd>" probe → ran <cmd>
|
|
502
|
+
* git --git-dir=<dir> diff --no-index <A> <B> → ran <cmd>
|
|
503
|
+
* HOME=<dir> git diff --no-index <A> <B> → ran <cmd>
|
|
504
|
+
* XDG_CONFIG_HOME=<dir> git diff --no-index <A> <B> → ran <cmd>
|
|
505
|
+
*
|
|
506
|
+
* Category 3 is the worst of them. The minimum viable evil "repo" is ONE
|
|
507
|
+
* PLAIN-TEXT FILE: `--no-index` means git never validates the directory as a
|
|
508
|
+
* repository, so there is no `objects/`, no `refs/`, no `git init` and no exec
|
|
509
|
+
* bit — a session writes the config with its ordinary write tool.
|
|
510
|
+
* `--ext-diff` is NOT required, because `git diff` honours `diff.external` by
|
|
511
|
+
* default. Note the implicit SEARCH PATH (`HOME`, `XDG_CONFIG_HOME`) is as
|
|
512
|
+
* much a relocator as the explicit pointers (`GIT_CONFIG_GLOBAL`,
|
|
513
|
+
* `GIT_CONFIG_SYSTEM`).
|
|
514
|
+
*
|
|
515
|
+
* `-C <dir>` is deliberately NOT here. `-C` DOES reach config: it chdirs, git
|
|
516
|
+
* then DISCOVERS the repository there and reads its config, arriving at the
|
|
517
|
+
* same `diff.external`. It stays allowed because discovery requires a VALID
|
|
518
|
+
* repository whose config the session controls, and `git init` and
|
|
519
|
+
* `git clone` are both off the verb allowlist above — so the session cannot
|
|
520
|
+
* create one. That is a real bound, but a different one from "cannot reach
|
|
521
|
+
* config".
|
|
522
|
+
*
|
|
523
|
+
* Options that can inject commands on either side of the verb. Top-level
|
|
524
|
+
* `-c` is checked separately before the verb.
|
|
525
|
+
*
|
|
526
|
+
* `git show -c` / `git log -c` / `git diff-tree -c` is git's COMBINED
|
|
527
|
+
* MERGE DIFF option, not config injection — and it is exactly the tool the
|
|
528
|
+
* recheck needs: a hand-resolved conflict is audited against BOTH merge
|
|
529
|
+
* parents. Git's top-level `-c` must precede the subcommand anyway, so the
|
|
530
|
+
* before-verb check loses nothing.
|
|
531
|
+
*/
|
|
532
|
+
const GIT_COMMAND_INJECTING_OPTS = /^(?:--config-env|--upload-pack|--receive-pack|--exec|--exec-path|--git-dir|--work-tree)(?:=|$)/;
|
|
533
|
+
/**
|
|
534
|
+
* The read verbs' own WRITE option. `--output=<file>` (and `--output <file>`)
|
|
535
|
+
* is a diff option: `git log`, `git show`, `git diff` and every verb that
|
|
536
|
+
* takes diff options write their output INTO THAT FILE, and
|
|
537
|
+
* `--format=format:<text>` or a committed blob (`git show HEAD:<path>`) makes
|
|
538
|
+
* the bytes anything at all. VERIFIED (git 2.55): `git show HEAD:a.json
|
|
539
|
+
* --output <dir>/o2` wrote the blob byte for byte — past the write veto,
|
|
540
|
+
* which reads redirections and `sed -i` only. A reviewer reads output on
|
|
541
|
+
* stdout; it never needs git to write a file. Exact name only: git refuses
|
|
542
|
+
* an abbreviation (`--outp`, `--outpu` — ambiguous with
|
|
543
|
+
* `--output-indicator-*`), and `--output-indicator-new` is a read option.
|
|
544
|
+
*/
|
|
545
|
+
const GIT_OUTPUT_FILE_OPT = /^--output(?:=|$)/;
|
|
546
|
+
/**
|
|
547
|
+
* `git grep`'s `--open-files-in-pager[=<cmd>]` / `-O[<cmd>]` RUNS the named
|
|
548
|
+
* program on the matching files — VERIFIED: `git grep -O"touch <f>" a` and
|
|
549
|
+
* `git grep -nO…` created the file. It is arbitrary execution through a read
|
|
550
|
+
* verb, not a config relocation, so it is its own rule. parse-options accepts
|
|
551
|
+
* any unique abbreviation of the long form (`--op`, `--open=…` VERIFIED),
|
|
552
|
+
* and `-O` can end a short cluster (`-nO<cmd>`) — so a short cluster is
|
|
553
|
+
* walked letter by letter, stopping at
|
|
554
|
+
* the options that take the rest of the word as their value.
|
|
555
|
+
*/
|
|
556
|
+
const GIT_GREP_PAGER_LONG = "open-files-in-pager";
|
|
557
|
+
const GIT_GREP_SHORT_WITH_VALUE = new Set(["A", "B", "C", "e", "f", "m"]);
|
|
558
|
+
function isGitGrepPagerOpt(a) {
|
|
559
|
+
const long = /^--([a-z-]+)(?:=|$)/.exec(a);
|
|
560
|
+
if (long)
|
|
561
|
+
return long[1].length >= 2 && GIT_GREP_PAGER_LONG.startsWith(long[1]);
|
|
562
|
+
if (!/^-[^-]/.test(a))
|
|
563
|
+
return false;
|
|
564
|
+
for (const ch of a.slice(1)) {
|
|
565
|
+
if (ch === "O")
|
|
566
|
+
return true;
|
|
567
|
+
if (GIT_GREP_SHORT_WITH_VALUE.has(ch))
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
function checkGit(rawGitArgs) {
|
|
573
|
+
// Judged as git RECEIVES them: bash removes inner quotes and backslashes
|
|
574
|
+
// before git parses an option, so `--up"load"-pack=…`, `--git-"dir"=…` and
|
|
575
|
+
// `--out"put"=…` are the options they spell.
|
|
576
|
+
const args = rawGitArgs.map(dequote).filter((a) => a.length > 0);
|
|
577
|
+
// Leading global options, before the verb.
|
|
578
|
+
let i = 0;
|
|
579
|
+
while (i < args.length && args[i].startsWith("-")) {
|
|
580
|
+
if (/^-c(?:=|$)/.test(args[i]) || GIT_COMMAND_INJECTING_OPTS.test(args[i])) {
|
|
581
|
+
return block(`exec policy: 'git ${args[i]}' can hand git a command to run (alias/pager/sshCommand/diff.external) — the reviewer never executes anything`);
|
|
582
|
+
}
|
|
583
|
+
if (args[i] === "-C")
|
|
584
|
+
i++; // a directory, not a verb — skip its value
|
|
585
|
+
i++;
|
|
586
|
+
}
|
|
587
|
+
const verb = args[i];
|
|
588
|
+
if (verb === undefined)
|
|
589
|
+
return { allowed: true }; // bare `git` prints usage
|
|
590
|
+
// …and after it, where the program-naming and config-relocating forms are
|
|
591
|
+
// equally effective. `-c` is excluded here: see the constant above.
|
|
592
|
+
for (const a of args.slice(i + 1)) {
|
|
593
|
+
if (GIT_COMMAND_INJECTING_OPTS.test(a)) {
|
|
594
|
+
return block(`exec policy: 'git ${verb} ${a}' can hand git a command to run — the reviewer never executes anything`);
|
|
595
|
+
}
|
|
596
|
+
if (GIT_OUTPUT_FILE_OPT.test(a)) {
|
|
597
|
+
return block(`exec policy: 'git ${verb} ${a}' makes git WRITE its output into a file of the caller's choosing — the reviewer reads git's output on stdout and never writes a file through it.`);
|
|
598
|
+
}
|
|
599
|
+
if (verb === "grep" && isGitGrepPagerOpt(a)) {
|
|
600
|
+
return block(`exec policy: 'git grep ${a}' runs a program on the matching files — the reviewer never executes anything. Read the matches on stdout.`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const sub = GIT_READONLY_SUBVERBS[verb];
|
|
604
|
+
if (sub !== undefined) {
|
|
605
|
+
// POSITIONAL, not `some()` over every argument. A scan of all args would
|
|
606
|
+
// accept the read-only marker wherever it appeared — including as the
|
|
607
|
+
// VALUE of a write form, e.g. `git branch -m list`. The first token after
|
|
608
|
+
// the verb is the one that decides what the command does.
|
|
609
|
+
return sub.has(args[i + 1] ?? "")
|
|
610
|
+
? { allowed: true }
|
|
611
|
+
: block(`exec policy: 'git ${verb}' is only allowed in its read-only form (${[...sub].join(", ")})`);
|
|
612
|
+
}
|
|
613
|
+
if (verb === "push" || verb === "send-pack") {
|
|
614
|
+
return block(`exec policy: 'git ${verb}' is a GitHub mutation — every write goes through the ONE mutation gate, never a session's shell`);
|
|
615
|
+
}
|
|
616
|
+
if (GIT_READONLY_VERBS.has(verb))
|
|
617
|
+
return { allowed: true };
|
|
618
|
+
return block(`exec policy: 'git ${verb}' is not a sanctioned read-only git command — this worktree is the daemon's and the reviewer only reads it (use 'git show <rev>:<path>' to read another revision)`);
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Environment variables that name a PROGRAM for an otherwise-allowed command to
|
|
622
|
+
* run — the env twin of `git -c`. `VAR=val` prefixes are skipped by the
|
|
623
|
+
* splitter below, so without this a read verb carries the payload instead of
|
|
624
|
+
* the verb doing it.
|
|
625
|
+
*
|
|
626
|
+
* VERIFIED, not inferred:
|
|
627
|
+
* GIT_EXTERNAL_DIFF="<cmd>" git diff --ext-diff -- <file> → ran <cmd>
|
|
628
|
+
*
|
|
629
|
+
* The git family (pager, external diff, ssh, editor, askpass, and the
|
|
630
|
+
* `GIT_CONFIG_*` set, which reproduces `-c` exactly) plus the generic loader
|
|
631
|
+
* and shell hooks that would subvert ANY allowed program.
|
|
632
|
+
*/
|
|
633
|
+
const COMMAND_INJECTING_ENV = new Set([
|
|
634
|
+
"GIT_PAGER",
|
|
635
|
+
"PAGER",
|
|
636
|
+
"GIT_EXTERNAL_DIFF",
|
|
637
|
+
"GIT_DIFF_OPTS",
|
|
638
|
+
"GIT_SSH",
|
|
639
|
+
"GIT_SSH_COMMAND",
|
|
640
|
+
"GIT_EDITOR",
|
|
641
|
+
"GIT_SEQUENCE_EDITOR",
|
|
642
|
+
"GIT_ASKPASS",
|
|
643
|
+
"GIT_PROXY_COMMAND",
|
|
644
|
+
"GIT_CONFIG",
|
|
645
|
+
"GIT_CONFIG_GLOBAL",
|
|
646
|
+
"GIT_CONFIG_SYSTEM",
|
|
647
|
+
"GIT_CONFIG_COUNT",
|
|
648
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
649
|
+
"GIT_TEMPLATE_DIR",
|
|
650
|
+
// The env twins of `--git-dir` / `--work-tree` / `--exec-path`: the
|
|
651
|
+
// config-RELOCATING third member of the family, at the REPO scope.
|
|
652
|
+
// `GIT_DIR=<dir> git diff --no-index A B` runs `diff.external` out of
|
|
653
|
+
// `<dir>/config` — VERIFIED executing, with `<dir>` holding nothing but
|
|
654
|
+
// that one plain-text file.
|
|
655
|
+
"GIT_DIR",
|
|
656
|
+
"GIT_COMMON_DIR",
|
|
657
|
+
"GIT_WORK_TREE",
|
|
658
|
+
"GIT_EXEC_PATH",
|
|
659
|
+
// …and the same category at the GLOBAL/XDG scope. `GIT_CONFIG_GLOBAL` and
|
|
660
|
+
// `GIT_CONFIG_SYSTEM` above are the EXPLICIT pointers; these two are the
|
|
661
|
+
// implicit SEARCH PATH those fall back to — `$HOME/.gitconfig` and
|
|
662
|
+
// `$XDG_CONFIG_HOME/git/config`. Same primitive and the same cost as the
|
|
663
|
+
// repo-scope form: one plain-text file written with the ordinary write tool,
|
|
664
|
+
// plus one ALLOWED command.
|
|
665
|
+
// VERIFIED executing: HOME=<dir> git diff --no-index A B.
|
|
666
|
+
"HOME",
|
|
667
|
+
"XDG_CONFIG_HOME",
|
|
668
|
+
"EDITOR",
|
|
669
|
+
"VISUAL",
|
|
670
|
+
"SSH_ASKPASS",
|
|
671
|
+
"LD_PRELOAD",
|
|
672
|
+
"LD_AUDIT",
|
|
673
|
+
"LD_LIBRARY_PATH",
|
|
674
|
+
"DYLD_INSERT_LIBRARIES",
|
|
675
|
+
"BASH_ENV",
|
|
676
|
+
"ENV",
|
|
677
|
+
"PATH",
|
|
678
|
+
"SHELL",
|
|
679
|
+
"IFS",
|
|
680
|
+
"PYTHONSTARTUP",
|
|
681
|
+
"NODE_OPTIONS",
|
|
682
|
+
"PERL5OPT",
|
|
683
|
+
]);
|
|
684
|
+
/** `GIT_CONFIG_KEY_0=alias.x GIT_CONFIG_VALUE_0=!cmd` is `-c` by another name. */
|
|
685
|
+
const COMMAND_INJECTING_ENV_RE = /^GIT_CONFIG_(KEY|VALUE)_\d+$/;
|
|
686
|
+
/**
|
|
687
|
+
* Environment variables that RELOCATE this system's own policy inputs — the
|
|
688
|
+
* same primitive as `HOME=` above, aimed at us instead of at git (fenced by
|
|
689
|
+
* `tests/fences.test.ts` and `tests/bash-guard.test.ts`).
|
|
690
|
+
*
|
|
691
|
+
* The posting scripts are sanctioned, and every guard knob they consult is
|
|
692
|
+
* read out of `$QCR_CONFIG`. Without this,
|
|
693
|
+
*
|
|
694
|
+
* QCR_CONFIG=/tmp/mine.toml bash scripts/approve-pr.sh 5 body.md
|
|
695
|
+
*
|
|
696
|
+
* would pass: `guard_enabled` treats a knob as ON unless the value is
|
|
697
|
+
* literally `false`, so one hand-written TOML file — created with the ordinary
|
|
698
|
+
* write tool — turns guards 2 through 5 off on a REAL approval. The same file
|
|
699
|
+
* carries `dry_run`, so it would also flip a dry-run install into posting for
|
|
700
|
+
* real (AGENTS.md invariant 6).
|
|
701
|
+
*
|
|
702
|
+
* `QCR_STATE_DB` relocates the database the guards query for the head mark and
|
|
703
|
+
* the approval-withheld state; `QCR_DRYRUN_DIR` relocates where a suppressed
|
|
704
|
+
* intent is recorded, which is the only evidence a dry-run run leaves.
|
|
705
|
+
*
|
|
706
|
+
* SCOPE: this closes the ENV-PREFIX spelling. The two further routes —
|
|
707
|
+
* `bash -c '<sanctioned script> ; <anything>'` past the segment policy, and
|
|
708
|
+
* overwriting the config / the posting scripts / the compiled
|
|
709
|
+
* guard outright — are closed elsewhere: the `bash -c` payload is re-parsed as
|
|
710
|
+
* its own command (`bashCPayload`, from `checkSegment`), and every
|
|
711
|
+
* policy-carrying file is unwritable at BOTH the write-tool and the
|
|
712
|
+
* shell-redirection boundary (`isPolicySurface`).
|
|
713
|
+
*
|
|
714
|
+
* `QCR_POSTING_RECEIPT_DIR` is a relocator, not an argument: it names a
|
|
715
|
+
* DIRECTORY, exactly like `QCR_DRYRUN_DIR` and `QCR_STATE_DB` beside it, not a
|
|
716
|
+
* per-call value like `QCR_EXPECTED_HEAD`; the daemon never sets it (it relies
|
|
717
|
+
* on the scripts' default `$ROOT/state/posting-receipts`, and `verdicts.ts`
|
|
718
|
+
* reads that same default), so blocking it breaks no dispatch path; and a
|
|
719
|
+
* session has no business moving where the posting RECEIPT — the record that
|
|
720
|
+
* settles a round — is written.
|
|
721
|
+
*
|
|
722
|
+
* `QCR_INSTALL` is a relocator too: the install locator (src/install.ts,
|
|
723
|
+
* mirrored by scripts/install-locator.sh) finds the live config in
|
|
724
|
+
* `$XDG_CONFIG_HOME/qcr/$QCR_INSTALL/`, so naming another install is
|
|
725
|
+
* `QCR_CONFIG=` by another spelling. (`HOME=` and `XDG_CONFIG_HOME=`, the
|
|
726
|
+
* other two inputs, are refused above as git relocators.) The daemon hands
|
|
727
|
+
* each session the path it resolved as `QCR_CONFIG` in the pane environment —
|
|
728
|
+
* a session's own `QCR_CONFIG=` prefix is still refused here.
|
|
729
|
+
*
|
|
730
|
+
* NOT here, deliberately: `QCR_EXPECTED_HEAD`, `QCR_POSTING_KEY`,
|
|
731
|
+
* `QCR_ROUND_NO`, `QCR_POST_RESPONSE`. Those are per-invocation ARGUMENTS the
|
|
732
|
+
* daemon hands the script — a session setting one changes what this call is
|
|
733
|
+
* about, which the guards then check; it does not change what the guards ARE.
|
|
734
|
+
* Blocking them would break the daemon's own dispatch path.
|
|
735
|
+
*/
|
|
736
|
+
const POLICY_RELOCATING_ENV = new Set([
|
|
737
|
+
"QCR_CONFIG",
|
|
738
|
+
"QCR_SANDBOX_ROOT",
|
|
739
|
+
"QCR_INSTALL",
|
|
740
|
+
"QCR_STATE_DB",
|
|
741
|
+
"QCR_DRYRUN_DIR",
|
|
742
|
+
"QCR_POSTING_RECEIPT_DIR",
|
|
743
|
+
]);
|
|
744
|
+
/**
|
|
745
|
+
* Env prefixes that move where a sanctioned script SENDS the App's credentials.
|
|
746
|
+
* Every script that talks to GitHub reads its host from `GITHUB_API`, and
|
|
747
|
+
* `mint-token.sh` posts the App's signed JWT to it to mint an installation
|
|
748
|
+
* token — so `GITHUB_API=http://anywhere scripts/mint-token.sh` would hand the
|
|
749
|
+
* JWT (which can mint a token for every installation of the App) to a host
|
|
750
|
+
* the session chose, through the one network channel this policy otherwise
|
|
751
|
+
* leaves closed: curl, wget and every interpreter are refused. The daemon never
|
|
752
|
+
* sets it for a session; the scripts' own default (`https://api.github.com`) is
|
|
753
|
+
* the only host a review needs. Tests point the scripts at a loopback stub by
|
|
754
|
+
* running them directly, never through this guard.
|
|
755
|
+
*/
|
|
756
|
+
const CREDENTIAL_RELOCATING_ENV = new Set(["GITHUB_API"]);
|
|
757
|
+
function envInjectionBlock(name) {
|
|
758
|
+
if (CREDENTIAL_RELOCATING_ENV.has(name)) {
|
|
759
|
+
return block(`exec policy: '${name}=' moves the host the sanctioned scripts send the App's credentials to — a session never chooses where they go`);
|
|
760
|
+
}
|
|
761
|
+
if (POLICY_RELOCATING_ENV.has(name)) {
|
|
762
|
+
return block(`exec policy: '${name}=' relocates the posting guards' own configuration — the guards are not a knob a session may move`);
|
|
763
|
+
}
|
|
764
|
+
if (!COMMAND_INJECTING_ENV.has(name) && !COMMAND_INJECTING_ENV_RE.test(name))
|
|
765
|
+
return null;
|
|
766
|
+
return block(`exec policy: '${name}=' names a program for another command to run — the reviewer never executes anything`);
|
|
767
|
+
}
|
|
768
|
+
/** One segment's program + args, env prefixes and subshell parens skipped.
|
|
769
|
+
* `segment` is the RAW text the tokens came from — needed for `bash -c`, whose
|
|
770
|
+
* payload the whitespace split has already shredded (see `bashCPayload`). */
|
|
771
|
+
function checkSegment(segment, tokens, depth, cwd, policy) {
|
|
772
|
+
let i = 0;
|
|
773
|
+
let afterEnv = false;
|
|
774
|
+
for (; i < tokens.length; i++) {
|
|
775
|
+
const raw = tokens[i].replace(/^['"(]+|['")]+$/g, "");
|
|
776
|
+
if (raw.length === 0)
|
|
777
|
+
continue;
|
|
778
|
+
const assign = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(raw);
|
|
779
|
+
if (assign) {
|
|
780
|
+
const refused = envInjectionBlock(assign[1]);
|
|
781
|
+
if (refused)
|
|
782
|
+
return refused;
|
|
783
|
+
continue; // VAR=val prefix
|
|
784
|
+
}
|
|
785
|
+
if (raw === "env") {
|
|
786
|
+
afterEnv = true;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (afterEnv && raw.startsWith("-")) {
|
|
790
|
+
// `env -S '<string>'` SPLITS its string into a program and arguments —
|
|
791
|
+
// a second command line inside this one (`env -S 'bash -c "git push"'`).
|
|
792
|
+
// Nothing a reviewer does needs it.
|
|
793
|
+
if (/^-[A-Za-z0-9]*S|^--split-string/.test(raw)) {
|
|
794
|
+
return block("exec policy: 'env -S' assembles a second command line inside this one — the reviewer never launders a command through env");
|
|
795
|
+
}
|
|
796
|
+
continue; // env -i etc.
|
|
797
|
+
}
|
|
798
|
+
if (/^\d*[<>]/.test(raw))
|
|
799
|
+
continue; // redirection remnant
|
|
800
|
+
// Program token found.
|
|
801
|
+
const rawArgs = tokens.slice(i + 1);
|
|
802
|
+
const args = rawArgs.map((t) => t.replace(/^['"]+|['"]+$/g, ""));
|
|
803
|
+
if (basenameOf(raw) === "bash") {
|
|
804
|
+
// `bash -c <payload>` is a whole command line inside ONE word, so it is
|
|
805
|
+
// judged from the RAW segment, never from these tokens.
|
|
806
|
+
const lifted = bashCPayload(segment);
|
|
807
|
+
if (lifted.kind === "payload") {
|
|
808
|
+
if (depth >= MAX_NESTED_COMMAND_DEPTH)
|
|
809
|
+
return block("exec policy: 'bash -c' nested too deep to analyse — refused");
|
|
810
|
+
return checkBashCommand(lifted.payload, depth + 1, cwd, policy);
|
|
811
|
+
}
|
|
812
|
+
if (lifted.kind === "unliftable")
|
|
813
|
+
return block(lifted.reason);
|
|
814
|
+
}
|
|
815
|
+
return checkProgram(raw, args, rawArgs, depth, textAfterWords(segment, i + 1), cwd, policy);
|
|
816
|
+
}
|
|
817
|
+
return { allowed: true }; // empty segment
|
|
818
|
+
}
|
|
819
|
+
/** Deepest `bash -c` nesting the guard will unwrap before refusing outright —
|
|
820
|
+
* a bound, because a payload can itself be another `bash -c`. */
|
|
821
|
+
const MAX_NESTED_COMMAND_DEPTH = 8;
|
|
822
|
+
/**
|
|
823
|
+
* Split text into shell WORDS: quote-aware, DEQUOTED, split only on unquoted
|
|
824
|
+
* whitespace. `'…'`, `"…"` (with `\"` and `\\`), `$'…'` (ANSI-C: `\n`, `\t`,
|
|
825
|
+
* `\\`, `\'`, `\"`) and backslash escapes are all resolved, so what comes out
|
|
826
|
+
* is what bash would hand the program as argv. Unlike the whitespace re-split
|
|
827
|
+
* in `checkBashCommand`, this preserves separators (`;`, newline, `&&`) that
|
|
828
|
+
* live INSIDE a quoted word — which is exactly what a `bash -c` payload is.
|
|
829
|
+
*/
|
|
830
|
+
export function shellWords(s) {
|
|
831
|
+
const ANSI_C = {
|
|
832
|
+
n: "\n",
|
|
833
|
+
t: "\t",
|
|
834
|
+
r: "\r",
|
|
835
|
+
"\\": "\\",
|
|
836
|
+
"'": "'",
|
|
837
|
+
'"': '"',
|
|
838
|
+
};
|
|
839
|
+
const words = [];
|
|
840
|
+
let cur = "";
|
|
841
|
+
let has = false;
|
|
842
|
+
let q = null;
|
|
843
|
+
let undecoded = false;
|
|
844
|
+
for (let i = 0; i < s.length; i++) {
|
|
845
|
+
const ch = s[i];
|
|
846
|
+
if (q === "'") {
|
|
847
|
+
if (ch === "'")
|
|
848
|
+
q = null;
|
|
849
|
+
else
|
|
850
|
+
cur += ch;
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (q === "$'") {
|
|
854
|
+
if (ch === "'")
|
|
855
|
+
q = null;
|
|
856
|
+
else if (ch === "\\") {
|
|
857
|
+
const next = s[i + 1] ?? "";
|
|
858
|
+
if (ANSI_C[next] === undefined)
|
|
859
|
+
undecoded = true;
|
|
860
|
+
cur += ANSI_C[next] ?? next;
|
|
861
|
+
i++;
|
|
862
|
+
}
|
|
863
|
+
else
|
|
864
|
+
cur += ch;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (q === '"') {
|
|
868
|
+
if (ch === '"')
|
|
869
|
+
q = null;
|
|
870
|
+
else if (ch === "\\" && (s[i + 1] === '"' || s[i + 1] === "\\")) {
|
|
871
|
+
cur += s[i + 1];
|
|
872
|
+
i++;
|
|
873
|
+
}
|
|
874
|
+
else
|
|
875
|
+
cur += ch;
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (ch === "$" && s[i + 1] === "'") {
|
|
879
|
+
q = "$'";
|
|
880
|
+
has = true;
|
|
881
|
+
i++;
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (ch === "'" || ch === '"') {
|
|
885
|
+
q = ch;
|
|
886
|
+
has = true;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (ch === "\\") {
|
|
890
|
+
cur += s[i + 1] ?? "";
|
|
891
|
+
i++;
|
|
892
|
+
has = true;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (/\s/.test(ch)) {
|
|
896
|
+
if (has) {
|
|
897
|
+
words.push(cur);
|
|
898
|
+
cur = "";
|
|
899
|
+
has = false;
|
|
900
|
+
}
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
cur += ch;
|
|
904
|
+
has = true;
|
|
905
|
+
}
|
|
906
|
+
if (has)
|
|
907
|
+
words.push(cur);
|
|
908
|
+
return { words, unterminated: q !== null, undecoded };
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* The `bash -c` payload of a raw segment, dequoted with its inner separators
|
|
912
|
+
* intact. Env prefixes and `env` are stepped over (the prefix itself is vetted
|
|
913
|
+
* by `checkSegment`). Two shapes are `unliftable`, and both REFUSE rather than
|
|
914
|
+
* fall through to a weaker check:
|
|
915
|
+
* · the payload is not literally present — the segment was cut at a `$(` or
|
|
916
|
+
* backtick, so bash would run whatever the substitution produces
|
|
917
|
+
* (`bash -c "$(cat x)"` — VERIFIED running the file's contents);
|
|
918
|
+
* · an option that takes a VALUE sits before `-c` (`-o pipefail`, `-O …`,
|
|
919
|
+
* `--rcfile f`, `--init-file f`), so which word is the payload depends on
|
|
920
|
+
* bash's option table rather than on position (`bash -o pipefail -c '…'`
|
|
921
|
+
* — VERIFIED running the payload).
|
|
922
|
+
* Nothing a reviewer does needs either. It is `bash` specifically: `sh -c` is
|
|
923
|
+
* refused outright.
|
|
924
|
+
*/
|
|
925
|
+
function bashCPayload(segment) {
|
|
926
|
+
const { words, unterminated, undecoded } = shellWords(segment);
|
|
927
|
+
let i = 0;
|
|
928
|
+
while (i < words.length) {
|
|
929
|
+
const w = words[i];
|
|
930
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
|
|
931
|
+
i++;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (w === "env") {
|
|
935
|
+
i++;
|
|
936
|
+
while (i < words.length && words[i].startsWith("-"))
|
|
937
|
+
i++;
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
const prog = words[i];
|
|
943
|
+
if (prog === undefined || basenameOf(prog) !== "bash")
|
|
944
|
+
return { kind: "none" };
|
|
945
|
+
for (let j = i + 1; j < words.length; j++) {
|
|
946
|
+
const w = words[j];
|
|
947
|
+
// A non-option, or `--` / `-` (end of options), before any -c: this is
|
|
948
|
+
// `bash <script>`, and checkProgram holds it to the sanctioned set.
|
|
949
|
+
if (!w.startsWith("-") || w === "-" || w === "--")
|
|
950
|
+
return { kind: "none" };
|
|
951
|
+
if (/^-[A-Za-z]*[oO]/.test(w) || /^--(?:rcfile|init-file)/.test(w)) {
|
|
952
|
+
return {
|
|
953
|
+
kind: "unliftable",
|
|
954
|
+
reason: `exec policy: 'bash ${w} …' — an option taking a value before -c hides which word is the command string; refused`,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w)) {
|
|
958
|
+
if (unterminated) {
|
|
959
|
+
return {
|
|
960
|
+
kind: "unliftable",
|
|
961
|
+
reason: "exec policy: this 'bash -c' payload is assembled at runtime by a substitution or an unclosed quote — the reviewer runs only commands the guard can read",
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
if (undecoded) {
|
|
965
|
+
return {
|
|
966
|
+
kind: "unliftable",
|
|
967
|
+
reason: "exec policy: this 'bash -c' payload uses a $'…' escape the guard cannot decode — the reviewer runs only commands the guard can read",
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
const next = words[j + 1];
|
|
971
|
+
return { kind: "payload", payload: (next === "--" ? words[j + 2] : next) ?? "" };
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return { kind: "none" };
|
|
975
|
+
}
|
|
976
|
+
function checkProgram(prog, args, rawArgs, depth,
|
|
977
|
+
/** The segment's text after `prog`, verbatim — quotes, and the newlines a
|
|
978
|
+
* whitespace split loses, intact. */
|
|
979
|
+
rawText, cwd, policy) {
|
|
980
|
+
// Sanctioned scripts, invoked directly.
|
|
981
|
+
const direct = sanctionedScript(prog, cwd, policy);
|
|
982
|
+
if (direct.name !== undefined || /(^|\/)scripts\//.test(prog)) {
|
|
983
|
+
return direct.sanctioned
|
|
984
|
+
? { allowed: true }
|
|
985
|
+
: block(notSanctioned(policy, direct.name, `exec policy: '${prog}' is not a sanctioned script`));
|
|
986
|
+
}
|
|
987
|
+
const base = basenameOf(prog);
|
|
988
|
+
if (base === "cd")
|
|
989
|
+
return { allowed: true }; // builtin, argument is a path not a program
|
|
990
|
+
// Checked before the allowlist so the reason is the directive one, and
|
|
991
|
+
// before `sh`/`bash`, which have their own narrower rules below.
|
|
992
|
+
if (AGENT_SPAWNERS.has(base))
|
|
993
|
+
return block(`${AGENT_SPAWN_REASON} — '${base}' is blocked`);
|
|
994
|
+
if (base === "sh") {
|
|
995
|
+
// No option after -n may switch execution back on or introduce a command.
|
|
996
|
+
const words = rawText === undefined ? args.map(dequote) : shellWords(rawText).words;
|
|
997
|
+
const next = words[1];
|
|
998
|
+
return words[0] === "-n" &&
|
|
999
|
+
(next === undefined || next === "--" || next === "-" || !/^[-+]/.test(next))
|
|
1000
|
+
? { allowed: true }
|
|
1001
|
+
: block("exec policy: 'sh' is only allowed as 'sh -n [--] <file>' (syntax check)");
|
|
1002
|
+
}
|
|
1003
|
+
if (base === "bash") {
|
|
1004
|
+
// A `bash -c` with a readable payload never reaches here: `checkSegment`
|
|
1005
|
+
// lifts it from the raw segment and re-parses it. What does reach here is
|
|
1006
|
+
// `bash -c` through `xargs`, or a -c form
|
|
1007
|
+
// `bashCPayload` did not recognise — and a command string nobody vetted
|
|
1008
|
+
// must not run, so any -c BEFORE the script is refused. Otherwise bash may
|
|
1009
|
+
// ONLY launch a sanctioned scripts/*.sh (flags before it allowed; anything
|
|
1010
|
+
// after the script is the script's own argument).
|
|
1011
|
+
for (const a of args) {
|
|
1012
|
+
if (!a.startsWith("-")) {
|
|
1013
|
+
const script = sanctionedScript(a, cwd, policy);
|
|
1014
|
+
return script.sanctioned
|
|
1015
|
+
? { allowed: true }
|
|
1016
|
+
: block(notSanctioned(policy, script.name, `exec policy: 'bash ${a}' is not a sanctioned scripts/*.sh`));
|
|
1017
|
+
}
|
|
1018
|
+
if (/^-[A-Za-z]*c/.test(a)) {
|
|
1019
|
+
return block("exec policy: this 'bash -c' carries a command string the guard cannot read (through xargs, or a form it does not lift) — refused");
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return block("exec policy: 'bash' may only run sanctioned scripts/*.sh");
|
|
1023
|
+
}
|
|
1024
|
+
if (base === "gh" || base === "git") {
|
|
1025
|
+
// gh and git are judged on the argv they RECEIVE. Plain quotes and
|
|
1026
|
+
// backslashes are removed exactly (`dequote`); ANSI-C `$'…'` and locale
|
|
1027
|
+
// `$"…"` quoting can spell any byte (or a translated string), so a word
|
|
1028
|
+
// carrying either is refused rather than guessed at.
|
|
1029
|
+
const unmodelled = args.find((a) => opensUnmodelledQuoting(a));
|
|
1030
|
+
if (unmodelled !== undefined) {
|
|
1031
|
+
return block(`exec policy: '${base}' argument ${unmodelled} uses $'…' or $"…" quoting, which the guard does not decode — spell it with plain quotes`);
|
|
1032
|
+
}
|
|
1033
|
+
if (base === "gh")
|
|
1034
|
+
return checkGh(args.map(dequote).filter((a) => a.length > 0));
|
|
1035
|
+
return checkGit(args.filter((a) => a.length > 0));
|
|
1036
|
+
}
|
|
1037
|
+
if (base === "xargs") {
|
|
1038
|
+
// xargs contributes arguments the guard cannot inspect. Classify its own
|
|
1039
|
+
// options before locating the program, including detached option values.
|
|
1040
|
+
let at = 0;
|
|
1041
|
+
for (; at < args.length; at++) {
|
|
1042
|
+
const option = dequote(args[at]);
|
|
1043
|
+
if (option === "--") {
|
|
1044
|
+
at++;
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
if (!option.startsWith("-"))
|
|
1048
|
+
break;
|
|
1049
|
+
if (/^(?:-[nLPsEId]|--(?:max-args|max-lines|max-procs|max-chars|delimiter))$/.test(option))
|
|
1050
|
+
at++;
|
|
1051
|
+
}
|
|
1052
|
+
const target = args[at];
|
|
1053
|
+
if (target === undefined)
|
|
1054
|
+
return { allowed: true };
|
|
1055
|
+
const from = at + 1;
|
|
1056
|
+
const checked = checkProgram(dequote(target), args.slice(from), rawArgs?.slice(from), depth, rawText === undefined ? undefined : textAfterWords(rawText, from), cwd, policy);
|
|
1057
|
+
if (!checked.allowed)
|
|
1058
|
+
return checked;
|
|
1059
|
+
const reader = basenameOf(dequote(target));
|
|
1060
|
+
if (["find", "uniq", "sh", "xargs"].includes(reader)) {
|
|
1061
|
+
return block(`exec policy: 'xargs ${reader}' can receive executable or output operands from stdin — use a direct, fully specified reader invocation`);
|
|
1062
|
+
}
|
|
1063
|
+
if (["sed", "sort", "git", "gh", "rg"].includes(reader)) {
|
|
1064
|
+
const replaces = args
|
|
1065
|
+
.slice(0, at)
|
|
1066
|
+
.some((a) => /^(?:-[a-zA-Z]*[Ii]|--replace)/.test(dequote(a)));
|
|
1067
|
+
if (replaces || dequote(args[args.length - 1] ?? "") !== "--") {
|
|
1068
|
+
return block(`exec policy: 'xargs ${reader}' without a closing '--' or with replacement — stdin must only supply file operands after all checked options`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return checked;
|
|
1072
|
+
}
|
|
1073
|
+
if (ALLOWED.has(base)) {
|
|
1074
|
+
// Allowlisted for READING is not allowlisted outright: sed/sort/find each
|
|
1075
|
+
// carry a write or exec mode (all VERIFIED).
|
|
1076
|
+
return checkReaderModes(base, args, rawArgs, rawText);
|
|
1077
|
+
}
|
|
1078
|
+
// ALLOWLIST MEMBERSHIP is what decides — this table only makes the refusal
|
|
1079
|
+
// name a replacement. Deliberately AFTER the check above: were it first,
|
|
1080
|
+
// removing `awk` from ALLOWED would be unobservable to the tests, and a
|
|
1081
|
+
// defence whose removal changes nothing is not a defence.
|
|
1082
|
+
const interpreter = INTERPRETER_REASONS[base];
|
|
1083
|
+
if (interpreter !== undefined)
|
|
1084
|
+
return block(interpreter);
|
|
1085
|
+
return block(`exec policy: '${base}' is not an allowed read-only command`);
|
|
1086
|
+
}
|
|
1087
|
+
/** Quote/substitution-aware segment splitter (a quote-blind split shreds
|
|
1088
|
+
* `rg 'a|b'` on the quoted pipe and vets pattern fragments as programs). Rules:
|
|
1089
|
+
* - single quotes: everything inside is literal;
|
|
1090
|
+
* - double quotes: separators are literal, but `$(` and backticks still
|
|
1091
|
+
* execute, so they still split (quote state restored at the matching `)`);
|
|
1092
|
+
* - backslash escapes the next char (outside single quotes);
|
|
1093
|
+
* - unquoted `; & | && || newline ( )` split; `$(`/`)` track nesting so the
|
|
1094
|
+
* command FOLLOWING a substitution lands in its own segment and is vetted
|
|
1095
|
+
* (merging it back would let `A="$(cat x)" python3 e.py` through). */
|
|
1096
|
+
export function splitShellSegments(cmd) {
|
|
1097
|
+
const segs = [];
|
|
1098
|
+
let cur = "";
|
|
1099
|
+
let q = null;
|
|
1100
|
+
const substStack = [];
|
|
1101
|
+
const push = () => {
|
|
1102
|
+
segs.push(cur);
|
|
1103
|
+
cur = "";
|
|
1104
|
+
};
|
|
1105
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
1106
|
+
const ch = cmd[i];
|
|
1107
|
+
if (ch === "\\" && q !== "'") {
|
|
1108
|
+
cur += ch + (cmd[i + 1] ?? "");
|
|
1109
|
+
i++;
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
if (q === "'") {
|
|
1113
|
+
if (ch === "'")
|
|
1114
|
+
q = null;
|
|
1115
|
+
cur += ch;
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
if (ch === "'" && q !== '"') {
|
|
1119
|
+
q = "'";
|
|
1120
|
+
cur += ch;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
if (ch === '"') {
|
|
1124
|
+
q = q === '"' ? null : '"';
|
|
1125
|
+
cur += ch;
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (ch === "$" && cmd[i + 1] === "(") {
|
|
1129
|
+
substStack.push(q);
|
|
1130
|
+
q = null;
|
|
1131
|
+
push();
|
|
1132
|
+
i++;
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
if (ch === ")" && substStack.length > 0) {
|
|
1136
|
+
q = substStack.pop();
|
|
1137
|
+
push();
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
if (ch === "`") {
|
|
1141
|
+
push();
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
if (q === '"') {
|
|
1145
|
+
cur += ch; // separators are literal inside double quotes
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
if (ch === "\n" || ch === ";" || ch === "(" || ch === ")") {
|
|
1149
|
+
push();
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (ch === "&" || ch === "|") {
|
|
1153
|
+
push();
|
|
1154
|
+
if (cmd[i + 1] === ch)
|
|
1155
|
+
i++;
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
cur += ch;
|
|
1159
|
+
}
|
|
1160
|
+
segs.push(cur);
|
|
1161
|
+
return segs;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Files that carry the OpenViking SERVER's root key.
|
|
1165
|
+
*
|
|
1166
|
+
* `cat` and `grep` are legitimately on the read-only allowlist, so the program
|
|
1167
|
+
* check alone would let a session read `~/.openviking/ov.conf` — which in
|
|
1168
|
+
* api_key mode holds `root_api_key`, an account-wide superuser that can list
|
|
1169
|
+
* every USER key in plaintext. That would hand a review session companion
|
|
1170
|
+
* memory, the exact reach the two-user design exists to deny.
|
|
1171
|
+
*
|
|
1172
|
+
* This is the third layer, not the only one: the key is never on an argv or in
|
|
1173
|
+
* a session's environment, and `writeOvcliConf` refuses to write it as a
|
|
1174
|
+
* session credential. Matching is on the SPELLING anywhere in the command, so
|
|
1175
|
+
* a redirect, a `--file=` form or an argument to any allowed reader is covered.
|
|
1176
|
+
*/
|
|
1177
|
+
const ROOT_CREDENTIAL_PATTERNS = [
|
|
1178
|
+
{
|
|
1179
|
+
re: /(^|[^A-Za-z0-9_-])ov\.conf\b/,
|
|
1180
|
+
what: "the OpenViking server config (it holds root_api_key)",
|
|
1181
|
+
},
|
|
1182
|
+
{ re: /openviking_root_key/, what: "the OpenViking root key secret" },
|
|
1183
|
+
{ re: /root_api_key/, what: "the OpenViking root key" },
|
|
1184
|
+
{ re: /openviking-keys\.env/, what: "the companion's OpenViking key file" },
|
|
1185
|
+
];
|
|
1186
|
+
/** The root-credential check, plus the App-secret belt below.
|
|
1187
|
+
* Exported so the reason is testable on its own. extensions/cli/bash-guard-ambient.ts
|
|
1188
|
+
* is the same call with `ctx` defaulted to this process's own cwd, home and
|
|
1189
|
+
* secrets dirs. */
|
|
1190
|
+
export function checkSecretPaths(cmd, ctx) {
|
|
1191
|
+
const root = checkRootCredentialSpelling(cmd);
|
|
1192
|
+
if (!root.allowed)
|
|
1193
|
+
return root;
|
|
1194
|
+
return checkAppSecretWords(cmd, ctx);
|
|
1195
|
+
}
|
|
1196
|
+
function checkRootCredentialSpelling(text) {
|
|
1197
|
+
for (const { re, what } of ROOT_CREDENTIAL_PATTERNS) {
|
|
1198
|
+
if (re.test(text)) {
|
|
1199
|
+
return block(`exec policy: reading ${what} is never sanctioned — review sessions authenticate as the reviewer USER and nothing else`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return { allowed: true };
|
|
1203
|
+
}
|
|
1204
|
+
/** The in-container secrets mount (deploy/ansible entrypoint `SECRETS`). */
|
|
1205
|
+
export const CONTAINER_SECRETS_DIR = "/opt/quickreview/secrets";
|
|
1206
|
+
/** The files the secrets dir holds, by their shipped names
|
|
1207
|
+
* (deploy/collect-secrets.sh, the src/config.ts defaults). */
|
|
1208
|
+
export const APP_SECRET_FILES = [
|
|
1209
|
+
"app.pem",
|
|
1210
|
+
"webhook_secret",
|
|
1211
|
+
"tunnel_token",
|
|
1212
|
+
"openai_api_key",
|
|
1213
|
+
"openai_embeddings_api_key",
|
|
1214
|
+
"slack_bot_token",
|
|
1215
|
+
"slack_app_token",
|
|
1216
|
+
// The reviewer's OpenViking key (the root key has its own rule above). The
|
|
1217
|
+
// session's memory extension reads it in-process, never through bash.
|
|
1218
|
+
"openviking_reviewer_key",
|
|
1219
|
+
];
|
|
1220
|
+
const APP_SECRET_NAME_RE = new RegExp(`^(?:${APP_SECRET_FILES.map(escapeRe).join("|")}|openai[A-Za-z0-9_.-]*key)$`);
|
|
1221
|
+
/** Secrets-dir spellings recognisable without any context. */
|
|
1222
|
+
const SECRET_DIR_SPELLINGS = [
|
|
1223
|
+
/^\/+opt\/+quickreview\/+secrets(?:\/|$)/,
|
|
1224
|
+
/(?:^|\/)qcr\/+[^/]+\/+secrets(?:\/|$)/,
|
|
1225
|
+
/(?:^|\/)\.config\/+quickreview-bot(?:\/|$)/, // the legacy dir — refused, never read
|
|
1226
|
+
];
|
|
1227
|
+
/**
|
|
1228
|
+
* This process's secrets dirs — self-contained on purpose (no `src/` import):
|
|
1229
|
+
* bash-guard is registered GLOBALLY on the VM and in the bench sandbox, and
|
|
1230
|
+
* must load wherever pi finds it. Mirrors `locateSecrets` in src/install.ts:
|
|
1231
|
+
* the container mount, `$QCR_SECRETS_MOUNT`, `$QC_SECRETS_DIR`,
|
|
1232
|
+
* `<config home>/secrets` (including absolute `$QCR_CONFIG_HOME`), the legacy `~/.config/quickreview-bot`, and a
|
|
1233
|
+
* `[github] secrets_dir` read best-effort from `$QCR_CONFIG`.
|
|
1234
|
+
*/
|
|
1235
|
+
export function defaultSecretPathContext(env, cwd) {
|
|
1236
|
+
const home = homeFor(env);
|
|
1237
|
+
const xdg = (env.XDG_CONFIG_HOME ?? "").trim() || `${home}/.config`;
|
|
1238
|
+
const install = (env.QCR_INSTALL ?? "").trim() || "default";
|
|
1239
|
+
const configOverride = (env.QCR_CONFIG_HOME ?? "").trim();
|
|
1240
|
+
const configDir = configOverride.startsWith("/")
|
|
1241
|
+
? configOverride.replace(/\/+$/, "")
|
|
1242
|
+
: `${xdg}/qcr/${install}`;
|
|
1243
|
+
const dirs = new Set([
|
|
1244
|
+
CONTAINER_SECRETS_DIR,
|
|
1245
|
+
`${configDir}/secrets`,
|
|
1246
|
+
`${home}/.config/quickreview-bot`, // the legacy dir — refused, never read
|
|
1247
|
+
]);
|
|
1248
|
+
for (const v of [
|
|
1249
|
+
env.QCR_SECRETS_MOUNT,
|
|
1250
|
+
env.QC_SECRETS_DIR,
|
|
1251
|
+
configuredSecretsDir(env.QCR_CONFIG),
|
|
1252
|
+
]) {
|
|
1253
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
1254
|
+
dirs.add(expandTilde(v.trim(), home));
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
cwd,
|
|
1258
|
+
env,
|
|
1259
|
+
dirs: [...dirs]
|
|
1260
|
+
.filter((d) => d.startsWith("/"))
|
|
1261
|
+
.map((d) => posix.normalize(d).replace(/(.)\/+$/, "$1")),
|
|
1262
|
+
runDirs: defaultRunDirs(env),
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
/** The IPC run dirs this process can name: the directory of its own socket
|
|
1266
|
+
* (a worker host's env) and the container's. Any other `<state>/run` is caught
|
|
1267
|
+
* by the spelling rule `RUN_DIR_SPELLING`. */
|
|
1268
|
+
function defaultRunDirs(env) {
|
|
1269
|
+
const out = new Set(["/opt/quickreview/state/run"]);
|
|
1270
|
+
const sock = (env.QCR_IPC_SOCKET ?? "").trim();
|
|
1271
|
+
if (sock.startsWith("/"))
|
|
1272
|
+
out.add(posix.dirname(sock));
|
|
1273
|
+
return [...out].map((d) => posix.normalize(d).replace(/(.)\/+$/, "$1"));
|
|
1274
|
+
}
|
|
1275
|
+
function homeFor(env) {
|
|
1276
|
+
return (env.HOME ?? "").trim() || "/root";
|
|
1277
|
+
}
|
|
1278
|
+
function expandTilde(p, home) {
|
|
1279
|
+
if (p === "~")
|
|
1280
|
+
return home;
|
|
1281
|
+
return p.startsWith("~/") ? `${home}/${p.slice(2)}` : p;
|
|
1282
|
+
}
|
|
1283
|
+
/** `[github] secrets_dir` from the live config, by a line scan — never a
|
|
1284
|
+
* reason to fail: an unreadable config simply adds nothing. */
|
|
1285
|
+
function configuredSecretsDir(configPath) {
|
|
1286
|
+
if (typeof configPath !== "string" || configPath.trim() === "")
|
|
1287
|
+
return null;
|
|
1288
|
+
try {
|
|
1289
|
+
let section = "";
|
|
1290
|
+
for (const line of readFileSync(configPath, "utf8").split("\n")) {
|
|
1291
|
+
const head = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/.exec(line);
|
|
1292
|
+
if (head) {
|
|
1293
|
+
section = head[1].trim();
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
if (section !== "github")
|
|
1297
|
+
continue;
|
|
1298
|
+
const m = /^\s*secrets_dir\s*=\s*["']([^"']*)["']/.exec(line);
|
|
1299
|
+
if (m && m[1].trim() !== "")
|
|
1300
|
+
return m[1].trim();
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
/* unreadable config: nothing to add */
|
|
1305
|
+
}
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
/** Programs whose first positional (or `-e` value) is a PATTERN or script,
|
|
1309
|
+
* not a file. */
|
|
1310
|
+
const PATTERN_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg", "sed"]);
|
|
1311
|
+
/** Their options that consume the NEXT word as a value, so the value is not
|
|
1312
|
+
* mistaken for the pattern. (`-e`/`--regexp`/`--expression` are handled
|
|
1313
|
+
* separately: their value IS the pattern.) */
|
|
1314
|
+
/** Value-taking in rg (`-E` an encoding, `-T` a type) but a plain switch in
|
|
1315
|
+
* grep (`-T` initial tab, `-E` extended regexes) and sed (`-E`). */
|
|
1316
|
+
const RG_ONLY_VALUE_OPTS = new Set(["-E", "-T"]);
|
|
1317
|
+
const PATTERN_PROGRAM_VALUE_OPTS = new Set([
|
|
1318
|
+
"-A",
|
|
1319
|
+
"-B",
|
|
1320
|
+
"-C",
|
|
1321
|
+
"-m",
|
|
1322
|
+
"-d",
|
|
1323
|
+
"-D",
|
|
1324
|
+
"-g",
|
|
1325
|
+
"-t",
|
|
1326
|
+
"-T",
|
|
1327
|
+
"-M",
|
|
1328
|
+
"-j",
|
|
1329
|
+
"-E",
|
|
1330
|
+
"--after-context",
|
|
1331
|
+
"--before-context",
|
|
1332
|
+
"--context",
|
|
1333
|
+
"--max-count",
|
|
1334
|
+
"--glob",
|
|
1335
|
+
"--iglob",
|
|
1336
|
+
"--type",
|
|
1337
|
+
"--type-not",
|
|
1338
|
+
"--max-columns",
|
|
1339
|
+
"--threads",
|
|
1340
|
+
"--encoding",
|
|
1341
|
+
"--sort",
|
|
1342
|
+
"--sortr",
|
|
1343
|
+
"--max-depth",
|
|
1344
|
+
"--color",
|
|
1345
|
+
"--colors",
|
|
1346
|
+
"--replace",
|
|
1347
|
+
"--path-separator",
|
|
1348
|
+
"--max-filesize",
|
|
1349
|
+
"--ignore-file",
|
|
1350
|
+
"--type-add",
|
|
1351
|
+
"--engine",
|
|
1352
|
+
"--devices",
|
|
1353
|
+
"--directories",
|
|
1354
|
+
"--binary-files",
|
|
1355
|
+
"--label",
|
|
1356
|
+
"--include",
|
|
1357
|
+
"--exclude",
|
|
1358
|
+
"--exclude-dir",
|
|
1359
|
+
"--line-length",
|
|
1360
|
+
]);
|
|
1361
|
+
function secretBlock(word, why) {
|
|
1362
|
+
return block(`exec policy: '${word}' ${why} — a review session never reads the App's credentials (app.pem, webhook_secret, tunnel_token, the OpenAI and Slack keys, or the secrets dir); the sanctioned scripts (mint-token.sh, the posting scripts) read them themselves`);
|
|
1363
|
+
}
|
|
1364
|
+
const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
1365
|
+
/** Preserve each shell word's original spelling so secret-path expansion can
|
|
1366
|
+
* still inspect quotes and escapes, while a multiword pattern stays one word. */
|
|
1367
|
+
function rawShellWords(text) {
|
|
1368
|
+
const words = [];
|
|
1369
|
+
let start = -1;
|
|
1370
|
+
let quote = null;
|
|
1371
|
+
for (let i = 0; i < text.length; i++) {
|
|
1372
|
+
const c = text[i];
|
|
1373
|
+
if (start < 0 && !/\s/.test(c))
|
|
1374
|
+
start = i;
|
|
1375
|
+
if (c === "\\" && quote !== "'") {
|
|
1376
|
+
i++;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
if (quote !== null) {
|
|
1380
|
+
if (c === quote)
|
|
1381
|
+
quote = null;
|
|
1382
|
+
}
|
|
1383
|
+
else if (c === "'" || c === '"')
|
|
1384
|
+
quote = c;
|
|
1385
|
+
else if (/\s/.test(c) && start >= 0) {
|
|
1386
|
+
words.push(text.slice(start, i));
|
|
1387
|
+
start = -1;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (start >= 0)
|
|
1391
|
+
words.push(text.slice(start));
|
|
1392
|
+
return words;
|
|
1393
|
+
}
|
|
1394
|
+
function checkAppSecretWords(cmd, ctx) {
|
|
1395
|
+
let cwd = ctx.cwd;
|
|
1396
|
+
for (const segment of splitShellSegments(cmd)) {
|
|
1397
|
+
const words = rawShellWords(segment);
|
|
1398
|
+
const exempt = patternWordIndexes(words);
|
|
1399
|
+
for (let i = 0; i < words.length; i++) {
|
|
1400
|
+
if (exempt.has(i))
|
|
1401
|
+
continue;
|
|
1402
|
+
const res = judgeSecretWord(words[i], { ...ctx, cwd });
|
|
1403
|
+
if (!res.allowed)
|
|
1404
|
+
return res;
|
|
1405
|
+
}
|
|
1406
|
+
// A `cd` moves where every later relative word lands. Approximate on
|
|
1407
|
+
// purpose (a subshell's cd leaks forward here) — that errs toward refusal.
|
|
1408
|
+
const p = programIndex(words);
|
|
1409
|
+
if (p >= 0 && basenameOf(dequoteShellWord(words[p])) === "cd") {
|
|
1410
|
+
const target = words.slice(p + 1).find((w) => !w.startsWith("-"));
|
|
1411
|
+
if (target !== undefined) {
|
|
1412
|
+
const t = expandShellWord(dequoteShellWord(target), ctx.env);
|
|
1413
|
+
if (!/[*?[$]/.test(t))
|
|
1414
|
+
cwd = posix.resolve(cwd, t);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return { allowed: true };
|
|
1419
|
+
}
|
|
1420
|
+
/** Index of a segment's program word: past `VAR=val` prefixes and `env`. */
|
|
1421
|
+
function programIndex(words) {
|
|
1422
|
+
let p = 0;
|
|
1423
|
+
while (p < words.length && (ASSIGNMENT_RE.test(words[p]) || /^\d*[<>]/.test(words[p])))
|
|
1424
|
+
p++;
|
|
1425
|
+
if (p < words.length && dequoteShellWord(words[p]) === "env") {
|
|
1426
|
+
p++;
|
|
1427
|
+
while (p < words.length && (words[p].startsWith("-") || ASSIGNMENT_RE.test(words[p])))
|
|
1428
|
+
p++;
|
|
1429
|
+
}
|
|
1430
|
+
return p < words.length ? p : -1;
|
|
1431
|
+
}
|
|
1432
|
+
/** Which words of a segment are a pattern/script argument of grep/rg/sed. */
|
|
1433
|
+
function patternWordIndexes(words) {
|
|
1434
|
+
const out = new Set();
|
|
1435
|
+
let p = programIndex(words);
|
|
1436
|
+
if (p < 0)
|
|
1437
|
+
return out;
|
|
1438
|
+
// `xargs grep foo` — the pattern belongs to xargs' program.
|
|
1439
|
+
if (basenameOf(dequoteShellWord(words[p])) === "xargs") {
|
|
1440
|
+
p++;
|
|
1441
|
+
while (p < words.length && words[p].startsWith("-"))
|
|
1442
|
+
p++;
|
|
1443
|
+
}
|
|
1444
|
+
if (p >= words.length || !PATTERN_PROGRAMS.has(basenameOf(dequoteShellWord(words[p]))))
|
|
1445
|
+
return out;
|
|
1446
|
+
const isRg = basenameOf(dequoteShellWord(words[p])) === "rg";
|
|
1447
|
+
let explicit = false;
|
|
1448
|
+
let firstPositional = -1;
|
|
1449
|
+
for (let i = p + 1; i < words.length; i++) {
|
|
1450
|
+
const w = dequoteShellWord(words[i]);
|
|
1451
|
+
if (w === "--") {
|
|
1452
|
+
if (firstPositional < 0 && i + 1 < words.length)
|
|
1453
|
+
firstPositional = i + 1;
|
|
1454
|
+
break;
|
|
1455
|
+
}
|
|
1456
|
+
if (w === "-f" || w === "--file") {
|
|
1457
|
+
explicit = true; // the next word names a file, never a literal pattern
|
|
1458
|
+
i++;
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (/^(?:-f.|--file=)/.test(w)) {
|
|
1462
|
+
explicit = true;
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
if (w === "-e" || w === "--regexp" || w === "--expression") {
|
|
1466
|
+
explicit = true;
|
|
1467
|
+
out.add(i + 1);
|
|
1468
|
+
i++;
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
if (/^(?:-e.|--regexp=|--expression=)/.test(w)) {
|
|
1472
|
+
explicit = true;
|
|
1473
|
+
out.add(i);
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
if (w.startsWith("-")) {
|
|
1477
|
+
if (PATTERN_PROGRAM_VALUE_OPTS.has(w) && (isRg || !RG_ONLY_VALUE_OPTS.has(w)))
|
|
1478
|
+
i++;
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
if (/^\d*[<>]/.test(w))
|
|
1482
|
+
continue;
|
|
1483
|
+
if (firstPositional < 0)
|
|
1484
|
+
firstPositional = i;
|
|
1485
|
+
}
|
|
1486
|
+
if (!explicit && firstPositional >= 0)
|
|
1487
|
+
out.add(firstPositional);
|
|
1488
|
+
return out;
|
|
1489
|
+
}
|
|
1490
|
+
function basenameOf(w) {
|
|
1491
|
+
return w.slice(w.lastIndexOf("/") + 1);
|
|
1492
|
+
}
|
|
1493
|
+
/** The word bash would see: `$'…'` escapes decoded, then quotes, backslashes
|
|
1494
|
+
* and backticks removed. (A glob char inside quotes is literal to bash, but
|
|
1495
|
+
* judging it as a glob only errs toward refusal.) */
|
|
1496
|
+
function dequoteShellWord(word) {
|
|
1497
|
+
const ansi = word.replace(/\$'((?:\\.|[^'\\])*)'/g, (_m, body) => body
|
|
1498
|
+
.replace(/\\x([0-9A-Fa-f]{1,2})/g, (_x, h) => String.fromCharCode(parseInt(h, 16)))
|
|
1499
|
+
.replace(/\\u([0-9A-Fa-f]{1,4})/g, (_x, h) => String.fromCharCode(parseInt(h, 16)))
|
|
1500
|
+
.replace(/\\([0-7]{1,3})/g, (_x, o) => String.fromCharCode(parseInt(o, 8))));
|
|
1501
|
+
return ansi.replace(/["'\\`]/g, "");
|
|
1502
|
+
}
|
|
1503
|
+
/** `~`, `$HOME`/`${HOME}`, `$XDG_CONFIG_HOME`, `$QCR_INSTALL` — the variables
|
|
1504
|
+
* a secrets path is spelled with. Anything else is left as written. */
|
|
1505
|
+
function expandShellWord(w, env) {
|
|
1506
|
+
const home = homeFor(env);
|
|
1507
|
+
const vars = {
|
|
1508
|
+
HOME: home,
|
|
1509
|
+
XDG_CONFIG_HOME: (env.XDG_CONFIG_HOME ?? "").trim() || `${home}/.config`,
|
|
1510
|
+
QCR_INSTALL: (env.QCR_INSTALL ?? "").trim() || "default",
|
|
1511
|
+
};
|
|
1512
|
+
const out = w.replace(/\$\{?(HOME|XDG_CONFIG_HOME|QCR_INSTALL)\}?(?![A-Za-z0-9_])/g, (_m, n) => vars[n]);
|
|
1513
|
+
return expandTilde(out, home);
|
|
1514
|
+
}
|
|
1515
|
+
/** `{a,b}` brace expansion, bounded at 64 results. */
|
|
1516
|
+
function braceExpand(w, budget = 64) {
|
|
1517
|
+
const m = /^(.*?)\{([^{}]*,[^{}]*)\}(.*)$/.exec(w);
|
|
1518
|
+
if (!m)
|
|
1519
|
+
return [w];
|
|
1520
|
+
const out = [];
|
|
1521
|
+
for (const alt of m[2].split(",")) {
|
|
1522
|
+
for (const x of braceExpand(`${m[1]}${alt}${m[3]}`, budget)) {
|
|
1523
|
+
out.push(x);
|
|
1524
|
+
if (out.length >= budget)
|
|
1525
|
+
return out;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
return out;
|
|
1529
|
+
}
|
|
1530
|
+
const GLOB_CHARS = /[*?[]/;
|
|
1531
|
+
function validClass(cls) {
|
|
1532
|
+
try {
|
|
1533
|
+
new RegExp(cls);
|
|
1534
|
+
return true;
|
|
1535
|
+
}
|
|
1536
|
+
catch {
|
|
1537
|
+
return false;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
function globToRegex(glob) {
|
|
1541
|
+
let re = "";
|
|
1542
|
+
for (let i = 0; i < glob.length; i++) {
|
|
1543
|
+
const c = glob[i];
|
|
1544
|
+
if (c === "*") {
|
|
1545
|
+
if (glob[i + 1] === "*") {
|
|
1546
|
+
re += ".*";
|
|
1547
|
+
i++;
|
|
1548
|
+
}
|
|
1549
|
+
else
|
|
1550
|
+
re += "[^/]*";
|
|
1551
|
+
}
|
|
1552
|
+
else if (c === "?")
|
|
1553
|
+
re += "[^/]";
|
|
1554
|
+
else if (c === "[") {
|
|
1555
|
+
const close = glob.indexOf("]", i + 2);
|
|
1556
|
+
if (close < 0)
|
|
1557
|
+
re += "\\[";
|
|
1558
|
+
else {
|
|
1559
|
+
let body = glob.slice(i + 1, close);
|
|
1560
|
+
if (body.startsWith("!"))
|
|
1561
|
+
body = "^" + body.slice(1);
|
|
1562
|
+
const cls = `[${body.replace(/\\/g, "\\\\")}]`;
|
|
1563
|
+
// A class JavaScript cannot compile (a reversed range: `[name:pass-a]` in a quoted
|
|
1564
|
+
// JSON argument) must not throw out of the guard. It widens to any one character
|
|
1565
|
+
// but `/`, the most a bracket expression can match in bash, so a judge built on
|
|
1566
|
+
// this regex refuses at least as much as the word could reach.
|
|
1567
|
+
re += validClass(cls) ? cls : "[^/]";
|
|
1568
|
+
i = close;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
else
|
|
1572
|
+
re += c.replace(/[.+^${}()|\\/]/g, "\\$&");
|
|
1573
|
+
}
|
|
1574
|
+
return new RegExp(`^${re}$`);
|
|
1575
|
+
}
|
|
1576
|
+
/** The concrete paths a glob is tried against: each dir, and each shipped
|
|
1577
|
+
* file inside it. */
|
|
1578
|
+
function secretCandidates(dirs) {
|
|
1579
|
+
const out = [];
|
|
1580
|
+
for (const d of dirs) {
|
|
1581
|
+
out.push(d);
|
|
1582
|
+
for (const f of APP_SECRET_FILES)
|
|
1583
|
+
out.push(`${d}/${f}`);
|
|
1584
|
+
}
|
|
1585
|
+
return out;
|
|
1586
|
+
}
|
|
1587
|
+
/** Do the glob's leading components match a whole secrets dir? Then whatever
|
|
1588
|
+
* follows them (`app_id`, a file the name list does not carry) is inside it. */
|
|
1589
|
+
function globPrefixHitsDir(absGlob, dirs) {
|
|
1590
|
+
const parts = absGlob.split("/").filter((c) => c.length > 0);
|
|
1591
|
+
return dirs.some((d) => {
|
|
1592
|
+
const dparts = d.split("/").filter((c) => c.length > 0);
|
|
1593
|
+
if (parts.length < dparts.length)
|
|
1594
|
+
return false;
|
|
1595
|
+
// `**` can span components; an any-depth prefix is the whole-path regex's job.
|
|
1596
|
+
const prefix = parts.slice(0, dparts.length);
|
|
1597
|
+
if (prefix.some((c) => c.includes("**")))
|
|
1598
|
+
return false;
|
|
1599
|
+
return globToRegex(`/${prefix.join("/")}`).test(d);
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
function judgeSecretWord(raw, ctx) {
|
|
1603
|
+
// Every piece of the word that could be a path: the word itself, the part
|
|
1604
|
+
// after a redirection operator, after `=` (`--file=`, `VAR=`), after a
|
|
1605
|
+
// short option (`-f/path`) and after an `@` (pi's and curl's file prefix).
|
|
1606
|
+
const pieces = new Set();
|
|
1607
|
+
for (const piece of dequoteShellWord(raw).split(/[<>|;&()]+/)) {
|
|
1608
|
+
if (piece === "")
|
|
1609
|
+
continue;
|
|
1610
|
+
pieces.add(piece);
|
|
1611
|
+
const eq = piece.indexOf("=");
|
|
1612
|
+
if (eq >= 0)
|
|
1613
|
+
pieces.add(piece.slice(eq + 1));
|
|
1614
|
+
if (/^-[A-Za-z]./.test(piece))
|
|
1615
|
+
pieces.add(piece.slice(2));
|
|
1616
|
+
if (piece.startsWith("@"))
|
|
1617
|
+
pieces.add(piece.slice(1));
|
|
1618
|
+
}
|
|
1619
|
+
for (const piece of pieces) {
|
|
1620
|
+
for (const w of braceExpand(expandShellWord(piece, ctx.env))) {
|
|
1621
|
+
const res = judgeSecretPath(w, ctx);
|
|
1622
|
+
if (!res.allowed)
|
|
1623
|
+
return res;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return { allowed: true };
|
|
1627
|
+
}
|
|
1628
|
+
/** `<anything>/state/run`, the dir itself or its socket or launch files: the
|
|
1629
|
+
* spelling that needs no context, for a path that is not in `runDirs`. */
|
|
1630
|
+
const RUN_DIR_SPELLING = /(?:^|\/)state\/+run(?:\/+(?:qcr\.sock|caps)(?:\/|$)|\/*$)/;
|
|
1631
|
+
function runBlock(w) {
|
|
1632
|
+
return block(`exec policy: '${w}' is inside the daemon's IPC run dir — the worker socket and the capability launch files; a review session never reads them`);
|
|
1633
|
+
}
|
|
1634
|
+
/** The daemon's IPC run dir (`<state>/run`) is closed to reads, by spelling:
|
|
1635
|
+
* the dir, everything under it, and a glob that can expand into it. */
|
|
1636
|
+
function judgeRunPath(w, abs, ctx) {
|
|
1637
|
+
const dirs = ctx.runDirs ?? [];
|
|
1638
|
+
if (RUN_DIR_SPELLING.test(abs))
|
|
1639
|
+
return runBlock(w);
|
|
1640
|
+
for (const d of dirs)
|
|
1641
|
+
if (abs === d || abs.startsWith(`${d}/`))
|
|
1642
|
+
return runBlock(w);
|
|
1643
|
+
if (GLOB_CHARS.test(w)) {
|
|
1644
|
+
const re = globToRegex(abs);
|
|
1645
|
+
if (dirs.some((d) => re.test(d) || re.test(`${d}/qcr.sock`) || re.test(`${d}/caps`)))
|
|
1646
|
+
return runBlock(w);
|
|
1647
|
+
if (globPrefixHitsDir(abs, dirs))
|
|
1648
|
+
return runBlock(w);
|
|
1649
|
+
}
|
|
1650
|
+
return { allowed: true };
|
|
1651
|
+
}
|
|
1652
|
+
/** One expanded, unquoted word judged as a path. */
|
|
1653
|
+
export function judgeSecretPath(w, ctx) {
|
|
1654
|
+
if (w === "")
|
|
1655
|
+
return { allowed: true };
|
|
1656
|
+
const components = w.split("/").filter((c) => c.length > 0);
|
|
1657
|
+
for (const c of components) {
|
|
1658
|
+
if (APP_SECRET_NAME_RE.test(c))
|
|
1659
|
+
return secretBlock(w, "names one of the App's secret files");
|
|
1660
|
+
}
|
|
1661
|
+
for (const re of SECRET_DIR_SPELLINGS) {
|
|
1662
|
+
if (re.test(w))
|
|
1663
|
+
return secretBlock(w, "names the App's secrets dir");
|
|
1664
|
+
}
|
|
1665
|
+
const abs = posix.resolve(ctx.cwd, w);
|
|
1666
|
+
for (const d of ctx.dirs) {
|
|
1667
|
+
if (abs === d || abs.startsWith(`${d}/`))
|
|
1668
|
+
return secretBlock(w, `resolves into the App's secrets dir (${d})`);
|
|
1669
|
+
}
|
|
1670
|
+
if (GLOB_CHARS.test(w)) {
|
|
1671
|
+
const re = globToRegex(abs);
|
|
1672
|
+
if (secretCandidates(ctx.dirs).some((p) => re.test(p)) || globPrefixHitsDir(abs, ctx.dirs))
|
|
1673
|
+
return secretBlock(w, "is a glob that can expand into the App's secrets dir");
|
|
1674
|
+
for (const c of components) {
|
|
1675
|
+
if (!GLOB_CHARS.test(c))
|
|
1676
|
+
continue;
|
|
1677
|
+
const cre = globToRegex(c);
|
|
1678
|
+
const literal = c.replace(/\[[^\]]*\]|[*?]/g, "").length;
|
|
1679
|
+
const hit = APP_SECRET_FILES.find((f) => cre.test(f) && literal * 2 >= f.length);
|
|
1680
|
+
if (hit !== undefined)
|
|
1681
|
+
return secretBlock(w, `is a glob that matches the App's secret file '${hit}'`);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return judgeRunPath(w, abs, ctx);
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* The file TOOLS' secret veto. pi's built-in `read` takes a `path` and
|
|
1688
|
+
* resolves `~` and a leading `@` itself (VERIFIED against pi 1.0.0's
|
|
1689
|
+
* dist/core/tools/read.js schema and path-utils.js `resolveToCwd`), so the
|
|
1690
|
+
* judgement the bash belt applies to a word applies to that path — plus the
|
|
1691
|
+
* OpenViking root-credential spellings.
|
|
1692
|
+
*/
|
|
1693
|
+
export function checkReadPath(path, c) {
|
|
1694
|
+
const root = checkRootCredentialSpelling(path);
|
|
1695
|
+
if (!root.allowed)
|
|
1696
|
+
return root;
|
|
1697
|
+
const p = path.startsWith("@") ? path.slice(1) : path;
|
|
1698
|
+
const w = expandShellWord(p, c.env);
|
|
1699
|
+
const proc = judgeProcPath(w, c.cwd);
|
|
1700
|
+
if (!proc.allowed)
|
|
1701
|
+
return proc;
|
|
1702
|
+
return judgeSecretPath(w, c);
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* A process's environment, memory, command line and open descriptors —
|
|
1706
|
+
* `/proc/<pid>/environ`, `/mem`, `/cmdline`, `/fd/…` and `/fdinfo/…`.
|
|
1707
|
+
*
|
|
1708
|
+
* The descriptors are the IPC capability's door: a worker host holds its
|
|
1709
|
+
* command's capability as an inherited fd, and on the bot every process shares
|
|
1710
|
+
* one uid, so `cat /proc/<pid>/fd/<n>` would hand another command's capability
|
|
1711
|
+
* to the model. The same spelling rule closes it.
|
|
1712
|
+
*
|
|
1713
|
+
* Same family as the secret belt above, different payload: a review session's
|
|
1714
|
+
* parent process holds credentials in memory and argv (the engine's env, the
|
|
1715
|
+
* token handed over at spawn), and `cat` is a sanctioned reader, so
|
|
1716
|
+
* `cat /proc/$PPID/environ` would hand them to the model. Yama's
|
|
1717
|
+
* `ptrace_scope >= 1` blocks a sibling's `/proc/<pid>/mem` where the host sets
|
|
1718
|
+
* it; this rule is the same refusal everywhere, in the policy itself, and it
|
|
1719
|
+
* is not configurable.
|
|
1720
|
+
*
|
|
1721
|
+
* Judged on the SPELLING of every word, whatever program reads it (`cat`,
|
|
1722
|
+
* `head`, `xxd`, `strings`, `tr … <`, a redirect, `dd if=`): the path is
|
|
1723
|
+
* normalised against the cwd (a `cd /proc/self` earlier in the command moves
|
|
1724
|
+
* it), `/proc/<pid>/root/…` hops are followed, `$PPID`/`$$`/`$(…)` in a
|
|
1725
|
+
* component stand for any pid, and a glob is tried against concrete
|
|
1726
|
+
* candidates (`/proc/[0-9]*` + `/e*`, `/proc/self/*`). A grep/rg PATTERN is
|
|
1727
|
+
* exempt, as above: reviewing code that reads `/proc/self/environ` is
|
|
1728
|
+
* ordinary. `/proc/cpuinfo`, `/proc/self/status` and `ls /proc` stay open.
|
|
1729
|
+
*
|
|
1730
|
+
* Same reach as the secret belt: variable indirection into the directory
|
|
1731
|
+
* (`p=/proc/self; cat $p/environ`) is not followed.
|
|
1732
|
+
*/
|
|
1733
|
+
const PROC_PID = String.raw `(?:self|thread-self|\d+)`;
|
|
1734
|
+
const PROC_SECRET_RE = new RegExp(`^/proc/${PROC_PID}(?:/task/\\d+)?/(?:environ|mem|cmdline|fd(?:/.*)?|fdinfo(?:/.*)?)$`);
|
|
1735
|
+
const PROC_ROOT_RE = new RegExp(`^/proc/${PROC_PID}(?:/task/\\d+)?/root(/.*)?$`);
|
|
1736
|
+
const PROC_GLOB_CANDIDATES = (() => {
|
|
1737
|
+
const out = [];
|
|
1738
|
+
for (const pid of ["1", "4242", "self", "thread-self"]) {
|
|
1739
|
+
for (const leaf of ["environ", "mem", "cmdline", "fd", "fd/3", "fdinfo", "fdinfo/3"]) {
|
|
1740
|
+
out.push(`/proc/${pid}/${leaf}`, `/proc/${pid}/task/1/${leaf}`);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
out.push("/proc/self/root/proc/self/environ", "/proc/1/root/proc/1/environ");
|
|
1744
|
+
return out;
|
|
1745
|
+
})();
|
|
1746
|
+
/** A dynamic part of a word (`$VAR`, `${…}`, `$$`, `$(…)`, backticks) becomes
|
|
1747
|
+
* a one-component wildcard, so `/proc/$PPID/environ` reads as a glob. */
|
|
1748
|
+
function procDynamicToGlob(text) {
|
|
1749
|
+
return text
|
|
1750
|
+
.replace(/\$\((?:[^()]|\([^()]*\))*\)/g, "*")
|
|
1751
|
+
.replace(/`[^`]*`/g, "*")
|
|
1752
|
+
.replace(/\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*|[$!?#@*0-9-])/g, "*");
|
|
1753
|
+
}
|
|
1754
|
+
/** The command with substitutions opened up, so the words inside `$(…)` and
|
|
1755
|
+
* backticks are judged as words of their own. */
|
|
1756
|
+
function procOpenSubstitutions(text) {
|
|
1757
|
+
return text.replace(/\$\(|[`()]/g, " ");
|
|
1758
|
+
}
|
|
1759
|
+
function procSecretBlock(word) {
|
|
1760
|
+
return block(`exec policy: '${word}' reads a process's environment, memory or command line — never sanctioned; /proc/<pid>/environ, /mem and /cmdline hold credentials and argv that a review session has no business reading`);
|
|
1761
|
+
}
|
|
1762
|
+
/** One expanded word judged as a path. */
|
|
1763
|
+
export function judgeProcPath(w, cwd) {
|
|
1764
|
+
if (w === "")
|
|
1765
|
+
return { allowed: true };
|
|
1766
|
+
let abs = posix.resolve(cwd, w);
|
|
1767
|
+
for (let m = PROC_ROOT_RE.exec(abs); m !== null; m = PROC_ROOT_RE.exec(abs))
|
|
1768
|
+
abs = m[1] || "/";
|
|
1769
|
+
if (GLOB_CHARS.test(abs)) {
|
|
1770
|
+
const re = globToRegex(abs);
|
|
1771
|
+
if (PROC_GLOB_CANDIDATES.some((c) => re.test(c)))
|
|
1772
|
+
return procSecretBlock(w);
|
|
1773
|
+
return { allowed: true };
|
|
1774
|
+
}
|
|
1775
|
+
return PROC_SECRET_RE.test(abs) ? procSecretBlock(w) : { allowed: true };
|
|
1776
|
+
}
|
|
1777
|
+
function judgeProcWord(raw, cwd) {
|
|
1778
|
+
const pieces = new Set();
|
|
1779
|
+
for (const piece of dequoteShellWord(raw).split(/[\s<>|;&()]+/)) {
|
|
1780
|
+
if (piece === "")
|
|
1781
|
+
continue;
|
|
1782
|
+
pieces.add(piece);
|
|
1783
|
+
for (let eq = piece.indexOf("="); eq >= 0; eq = piece.indexOf("=", eq + 1))
|
|
1784
|
+
pieces.add(piece.slice(eq + 1));
|
|
1785
|
+
if (/^-[A-Za-z]./.test(piece))
|
|
1786
|
+
pieces.add(piece.slice(2));
|
|
1787
|
+
if (piece.startsWith("@"))
|
|
1788
|
+
pieces.add(piece.slice(1));
|
|
1789
|
+
}
|
|
1790
|
+
for (const piece of pieces) {
|
|
1791
|
+
for (const w of braceExpand(piece)) {
|
|
1792
|
+
const res = judgeProcPath(w, cwd);
|
|
1793
|
+
if (!res.allowed)
|
|
1794
|
+
return res;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return { allowed: true };
|
|
1798
|
+
}
|
|
1799
|
+
/** Refuse a command that names `/proc/<pid>/{environ,mem,cmdline,fd,fdinfo}`. */
|
|
1800
|
+
export function checkProcSecrets(cmd, cwd) {
|
|
1801
|
+
for (const text of [procOpenSubstitutions(cmd), procDynamicToGlob(cmd)]) {
|
|
1802
|
+
let at = cwd;
|
|
1803
|
+
for (const segment of splitShellSegments(text)) {
|
|
1804
|
+
const words = rawShellWords(segment);
|
|
1805
|
+
const exempt = patternWordIndexes(words);
|
|
1806
|
+
for (let i = 0; i < words.length; i++) {
|
|
1807
|
+
if (exempt.has(i))
|
|
1808
|
+
continue;
|
|
1809
|
+
const res = judgeProcWord(words[i], at);
|
|
1810
|
+
if (!res.allowed)
|
|
1811
|
+
return res;
|
|
1812
|
+
}
|
|
1813
|
+
const p = programIndex(words);
|
|
1814
|
+
if (p >= 0 && basenameOf(dequoteShellWord(words[p])) === "cd") {
|
|
1815
|
+
const target = words.slice(p + 1).find((w) => !w.startsWith("-"));
|
|
1816
|
+
if (target !== undefined) {
|
|
1817
|
+
const t = dequoteShellWord(target);
|
|
1818
|
+
if (!/[*?[$]/.test(t))
|
|
1819
|
+
at = posix.resolve(at, t);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return { allowed: true };
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Git's configuration and hook surface — the files that turn an ORDINARY git
|
|
1828
|
+
* command into arbitrary shell execution.
|
|
1829
|
+
*
|
|
1830
|
+
* This is the half of the exec boundary a command-string guard cannot reach:
|
|
1831
|
+
* once the decisive ingredient is A FILE THE SESSION WRITES, the command
|
|
1832
|
+
* itself can be innocent. VERIFIED executing, with the config-scope pin in
|
|
1833
|
+
* force:
|
|
1834
|
+
*
|
|
1835
|
+
* printf '[diff]\n\texternal = <cmd>\n' >> <clone>/.git/config
|
|
1836
|
+
* git diff <base> <head> → ran <cmd>
|
|
1837
|
+
*
|
|
1838
|
+
* `git diff <base> <head>` is the single most ordinary command a review session
|
|
1839
|
+
* runs. There is no token in it for any blocklist to catch, and no env pin to
|
|
1840
|
+
* apply: git has no "ignore the local config" switch, unlike the global and
|
|
1841
|
+
* system scopes (`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`/`GIT_CONFIG_NOSYSTEM`,
|
|
1842
|
+
* which `sessionGitConfigPins` in src/sessions.ts nails down at spawn).
|
|
1843
|
+
*
|
|
1844
|
+
* So the repo scope is closed at the WRITE boundary instead. The rule is not a
|
|
1845
|
+
* list of attacks — it is git's own list of the places it reads configuration
|
|
1846
|
+
* and executables from, which is closed and documented:
|
|
1847
|
+
*
|
|
1848
|
+
* · anything inside a `.git` directory — `config`, `config.worktree`,
|
|
1849
|
+
* `hooks/*`, `info/attributes`, `worktrees/<n>/config.worktree`
|
|
1850
|
+
* · `.gitconfig` — the global scope's file
|
|
1851
|
+
* · `<any>/git/config` — the XDG scope's file
|
|
1852
|
+
* · `.gitattributes` — the other half of `diff.*.textconv`
|
|
1853
|
+
* and `filter.*.clean`; useless to an attacker without a config, but it is
|
|
1854
|
+
* the same surface and a review session never writes one.
|
|
1855
|
+
*
|
|
1856
|
+
* A review session READS code (scoping rule 2); it has no legitimate reason to
|
|
1857
|
+
* write any of these, in the worktree or anywhere else. Its own writes — the
|
|
1858
|
+
* verdict file, the ledger, the review body and comment JSON — live under
|
|
1859
|
+
* `state/`, `verdict/` and `inject/`, none of which this touches.
|
|
1860
|
+
*
|
|
1861
|
+
* REACH, stated plainly: this binds the `write`/`edit` TOOL path and shell
|
|
1862
|
+
* REDIRECTION targets. It does NOT bind a write smuggled inside a quoted
|
|
1863
|
+
* program body (`awk 'BEGIN{print > "..."}'`) or through a sanctioned
|
|
1864
|
+
* script. Those remain the bash program allowlist's job, and the allowlist is
|
|
1865
|
+
* what keeps the set of programs that can write a file at all small.
|
|
1866
|
+
*/
|
|
1867
|
+
const GIT_CONFIG_NAMES = {
|
|
1868
|
+
directory: ".git",
|
|
1869
|
+
global: ".gitconfig",
|
|
1870
|
+
xdg: "git",
|
|
1871
|
+
config: "config",
|
|
1872
|
+
attributes: ".gitattributes",
|
|
1873
|
+
};
|
|
1874
|
+
const GIT_CONFIG_SURFACE_RE = [
|
|
1875
|
+
new RegExp(`(^|/)${escapeRe(GIT_CONFIG_NAMES.directory)}(/|$)`),
|
|
1876
|
+
new RegExp(`(^|/)${escapeRe(GIT_CONFIG_NAMES.global)}$`),
|
|
1877
|
+
new RegExp(`(^|/)${GIT_CONFIG_NAMES.xdg}/${GIT_CONFIG_NAMES.config}$`),
|
|
1878
|
+
new RegExp(`(^|/)${escapeRe(GIT_CONFIG_NAMES.attributes)}$`),
|
|
1879
|
+
];
|
|
1880
|
+
/**
|
|
1881
|
+
* Is this path part of git's config/hook surface? Exported so both boundaries
|
|
1882
|
+
* — the bash redirection scan below and the write/edit tool veto in `activate`
|
|
1883
|
+
* — ask ONE question, and a future third caller cannot ask a narrower one.
|
|
1884
|
+
*/
|
|
1885
|
+
function protectedPathSpelling(path) {
|
|
1886
|
+
return path.replace(/^['"]+|['"]+$/g, "").replace(/\/+$/, "");
|
|
1887
|
+
}
|
|
1888
|
+
export function isGitConfigSurface(path) {
|
|
1889
|
+
// Trailing shell noise a redirection token can carry; quotes are stripped by
|
|
1890
|
+
// the caller, and a bare `~` expansion never changes which rule matches.
|
|
1891
|
+
const p = protectedPathSpelling(path);
|
|
1892
|
+
if (p.length === 0)
|
|
1893
|
+
return false;
|
|
1894
|
+
return GIT_CONFIG_SURFACE_RE.some((re) => re.test(p));
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* This system's OWN policy-carrying surface — the analogue of
|
|
1898
|
+
* `isGitConfigSurface`, one level out. `isGitConfigSurface` protects the files
|
|
1899
|
+
* that turn an ordinary GIT command into shell execution; this protects the
|
|
1900
|
+
* files that decide what every layer of THIS boundary does. A review session
|
|
1901
|
+
* READS code (scoping rule 2); it never writes any of these, so a write to one
|
|
1902
|
+
* is always a reconfiguration of its own controls:
|
|
1903
|
+
*
|
|
1904
|
+
* · `config.toml` — the guards' and the gate's configuration. The
|
|
1905
|
+
* posting scripts read `dry_run` and every `[guards]` knob out of it per
|
|
1906
|
+
* invocation, and `guard_enabled` treats a knob as ON unless it is literally
|
|
1907
|
+
* `false`, so ONE write turns guards 1-5 off and flips a dry-run install to
|
|
1908
|
+
* live on the next sanctioned post — AGENTS.md invariant 6, by a redirection.
|
|
1909
|
+
* · `scripts/**` — the sanctioned posting route (the shims that
|
|
1910
|
+
* hand over to the daemon). Overwriting one posts around it.
|
|
1911
|
+
* · `dist/**` — the COMPILED artifacts the container actually
|
|
1912
|
+
* runs, `packages/pi-plugin/dist/extensions/bash-guard.js` among them: overwrite the compiled
|
|
1913
|
+
* guard and the boundary is off for every later round in that container.
|
|
1914
|
+
* · `extensions/**` — the guard's own source and the other pi
|
|
1915
|
+
* extensions, which `deploy.sh sync` ships and the build compiles.
|
|
1916
|
+
* · `prompts/**`, `agents/**`, `seeds/**` — the frozen seeds (rule 6); the
|
|
1917
|
+
* `agents/` rule also covers the BUILT `state/agents/**` pass bodies pi
|
|
1918
|
+
* loads each round (a `state/agents/…` path has `agents/` after a slash).
|
|
1919
|
+
* · `AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`
|
|
1920
|
+
* — pi's project-context files. pi loads the FIRST of them it finds in the
|
|
1921
|
+
* cwd into EVERY session and pass it starts there (`loadContextFileFromDir`,
|
|
1922
|
+
* pi 0.87.1, unchanged at 0.99.1 and 1.0.0 — VERIFY-ON-PIN: the candidate list is pi's), and every session
|
|
1923
|
+
* and pass runs in the sandbox root. One write plants instructions in every
|
|
1924
|
+
* later session until the next deploy — the overseer's included.
|
|
1925
|
+
* · `.pi/**` — pi's PROJECT resource dir: `settings.json`,
|
|
1926
|
+
* `extensions/`, `mcp.json` (pi 0.99+). Every project is trusted
|
|
1927
|
+
* (`defaultProjectTrust = "always"`, the no-stall decision), so a file
|
|
1928
|
+
* planted here loads into every later session and pass started in that
|
|
1929
|
+
* root: an extension runs as code, a `mcp.json` launches server processes,
|
|
1930
|
+
* and a `+builtin:mcp` in project settings re-enables what the global
|
|
1931
|
+
* settings switch off (packages/pi-plugin/src/pibuiltins.ts) — project entries override the
|
|
1932
|
+
* user's. As a SHAPE this also covers `<pi home>/.pi/agent/…`, the
|
|
1933
|
+
* session's own global pi settings and login; a session writes neither.
|
|
1934
|
+
*
|
|
1935
|
+
* The list is OURS, closed and documented (docs/exec-boundary.md), not a list
|
|
1936
|
+
* of attacks. `POLICY_SURFACE_FILES` / `POLICY_SURFACE_DIRS` — top-level names
|
|
1937
|
+
* RELATIVE TO THE SANDBOX ROOT — and `POLICY_SURFACE_PATHS` (whole paths under
|
|
1938
|
+
* it) are the ONE definition three consumers read:
|
|
1939
|
+
* the write/edit tool veto and the shell-redirection scan (both through
|
|
1940
|
+
* `isPolicySurface`, as a path SHAPE: the name after any prefix, because the
|
|
1941
|
+
* guard does not know the session's cwd) and layer 4's round-boundary
|
|
1942
|
+
* integrity check (`policySurfaceWatchPaths` in src/execboundary.ts, as
|
|
1943
|
+
* concrete paths under the root — the one layer that also sees the write no
|
|
1944
|
+
* command string can show, `T=config.toml; cat p > $T`). Being a SHAPE, the
|
|
1945
|
+
* write veto also refuses `scripts/…`, `dist/…` etc. inside the REVIEWED
|
|
1946
|
+
* repo's worktree; a reviewer never writes there either (scoping rule 2).
|
|
1947
|
+
* `state/agents/**` — the BUILT pass bodies — is refused at the write boundary
|
|
1948
|
+
* through the `agents/` shape but deliberately NOT watched by layer 4: the
|
|
1949
|
+
* daemon itself rebuilds it mid-round on feedback ingest and the day change.
|
|
1950
|
+
*/
|
|
1951
|
+
export const POLICY_SURFACE_FILES = [
|
|
1952
|
+
"config.toml",
|
|
1953
|
+
"AGENTS.override.md",
|
|
1954
|
+
"AGENTS.md",
|
|
1955
|
+
"AGENTS.MD",
|
|
1956
|
+
"CLAUDE.md",
|
|
1957
|
+
"CLAUDE.MD",
|
|
1958
|
+
];
|
|
1959
|
+
/** Companion-owned policy intents change normally during a round, so they
|
|
1960
|
+
* have the write veto without a round hash, like built state/agents bodies. */
|
|
1961
|
+
const DYNAMIC_POLICY_FILES = ["qcr-profile-write.json", ".qcr-profile-write.json.tmp"];
|
|
1962
|
+
export const POLICY_SURFACE_DIRS = [
|
|
1963
|
+
"scripts",
|
|
1964
|
+
"dist",
|
|
1965
|
+
"extensions",
|
|
1966
|
+
"prompts",
|
|
1967
|
+
"agents",
|
|
1968
|
+
"seeds",
|
|
1969
|
+
".pi",
|
|
1970
|
+
];
|
|
1971
|
+
/** Policy files named by their PATH under the sandbox root, not a bare name:
|
|
1972
|
+
* the review core's export map decides which compiled guard and gate an
|
|
1973
|
+
* `@quickchatai/pi-quickreview/...` import loads (every shim, every worker),
|
|
1974
|
+
* while a package.json anywhere else is no policy at all. */
|
|
1975
|
+
export const POLICY_SURFACE_PATHS = ["packages/pi-plugin/package.json"];
|
|
1976
|
+
function escapeRe(s) {
|
|
1977
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1978
|
+
}
|
|
1979
|
+
const POLICY_SURFACE_RE = [
|
|
1980
|
+
new RegExp(`(^|/)(?:${[...POLICY_SURFACE_FILES, ...DYNAMIC_POLICY_FILES].map(escapeRe).join("|")})$`),
|
|
1981
|
+
new RegExp(`(^|/)(?:${POLICY_SURFACE_DIRS.map(escapeRe).join("|")})/.+`),
|
|
1982
|
+
new RegExp(`(^|/)(?:${POLICY_SURFACE_PATHS.map(escapeRe).join("|")})$`),
|
|
1983
|
+
];
|
|
1984
|
+
/** Is this path part of this system's own policy surface? Exported so both
|
|
1985
|
+
* boundaries ask ONE question — see `POLICY_SURFACE_RE`. */
|
|
1986
|
+
export function isPolicySurface(path) {
|
|
1987
|
+
const p = protectedPathSpelling(path);
|
|
1988
|
+
if (p.length === 0)
|
|
1989
|
+
return false;
|
|
1990
|
+
return POLICY_SURFACE_RE.some((re) => re.test(p));
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* The MEMORY-SCOPE files — what decides how far a PR session's OWN memory
|
|
1994
|
+
* recall may reach. The daemon publishes both into the session's pi home,
|
|
1995
|
+
* beside its `ovcli.conf` (src/memory/ovsession.ts), and the vendored
|
|
1996
|
+
* OpenViking extension reads them on every prompt:
|
|
1997
|
+
*
|
|
1998
|
+
* · `recall-hints/<session>.json` — the RECALL HINT (`ovRecallHintPathIn`):
|
|
1999
|
+
* the round's query and the memory AREAS the session's recall and its
|
|
2000
|
+
* memory tools may reach (`experiences/` alone — vendored patches
|
|
2001
|
+
* 0008/0009/0011). One write of `{"areas":["finding-cases","events"]}`
|
|
2002
|
+
* would widen the session's own scope to the finding cases and the
|
|
2003
|
+
* feedback events no round may read, until the daemon writes the hint
|
|
2004
|
+
* again at the next round.
|
|
2005
|
+
* · `retired-uris.json` — the RETIRED list (`ovRetiredPathIn`) the recall
|
|
2006
|
+
* excludes. Emptied, it brings retired entries back into every prompt's
|
|
2007
|
+
* recall.
|
|
2008
|
+
*
|
|
2009
|
+
* A review session READS memory and never writes either file; the daemon does,
|
|
2010
|
+
* whole and atomically, every round. Matched as a SHAPE like the policy
|
|
2011
|
+
* surface — the distinctive name under ANY prefix, because the guard does not
|
|
2012
|
+
* know the pi home (and a `cd` into `.openviking/` drops that part of the
|
|
2013
|
+
* spelling) — and NOT watched by layer 4: the daemon rewrites the hint at
|
|
2014
|
+
* every round's dispatch, so a round-boundary hash would read its own work as
|
|
2015
|
+
* a change (the `state/agents/**` reasoning). The write veto is the
|
|
2016
|
+
* protection these files have.
|
|
2017
|
+
*/
|
|
2018
|
+
export const MEMORY_SCOPE_SURFACE = ["recall-hints/", "retired-uris.json"];
|
|
2019
|
+
const MEMORY_SCOPE_SURFACE_RE = [
|
|
2020
|
+
/(^|\/)recall-hints(\/|$)/, // the hint dir, any file in it (its `.tmp-<pid>` too)
|
|
2021
|
+
/(^|\/)retired-uris\.json(\.tmp-[^/]*)?$/, // the retired list, and its atomic-write temp
|
|
2022
|
+
];
|
|
2023
|
+
/** Is this path one of the memory-scope files a session's recall reads?
|
|
2024
|
+
* Exported so both write boundaries ask ONE question. */
|
|
2025
|
+
export function isMemoryScopeSurface(path) {
|
|
2026
|
+
const p = protectedPathSpelling(path);
|
|
2027
|
+
if (p.length === 0)
|
|
2028
|
+
return false;
|
|
2029
|
+
return MEMORY_SCOPE_SURFACE_RE.some((re) => re.test(p));
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* The WAKE-DELIVERY files — the daemon's inject dirs
|
|
2033
|
+
* (`<root>/inject/<session>/`, `PiSessionManager.injectDir`) and the inject
|
|
2034
|
+
* bridge's record of what pi accepted. The bridge takes pending wakes in name
|
|
2035
|
+
* order and moves each one pi accepts into `.done/`; the daemon reads that
|
|
2036
|
+
* move as "this turn is now running" — above all for the overseer, whose
|
|
2037
|
+
* Slack command rights are the served turn's (`src/slack/origin.ts`). One
|
|
2038
|
+
* planted `.done/<queued wake>.md`, or a queued wake emptied in place (the
|
|
2039
|
+
* bridge moves an empty file to `.done/` without serving it), would hand a
|
|
2040
|
+
* running turn the rights of one still queued; a file dropped into another
|
|
2041
|
+
* session's inject dir is a prompt in that session.
|
|
2042
|
+
*
|
|
2043
|
+
* A session never writes any of them; the daemon writes the wakes and the
|
|
2044
|
+
* bridge moves them. Matched as a SHAPE: the `inject` dir and anything in it
|
|
2045
|
+
* under any prefix, a `.done` dir and anything in it (a `cd` into the inject
|
|
2046
|
+
* dir drops the `inject/` part), and the wake file's own name
|
|
2047
|
+
* (`<ISO stamp>-<seq>[--cmd-<id>].md`, its atomic-write temp included — a `cd`
|
|
2048
|
+
* into the session's dir drops every directory). Not watched by layer 4: the
|
|
2049
|
+
* daemon and the bridge rewrite these dirs all the time, so a round-boundary
|
|
2050
|
+
* hash would report their own work. The write veto is the protection, and a
|
|
2051
|
+
* session has no program that moves or removes a file.
|
|
2052
|
+
*/
|
|
2053
|
+
export const DELIVERY_SURFACE = ["inject/", ".done/", "<wake file>.md"];
|
|
2054
|
+
const DELIVERY_SURFACE_RE = [
|
|
2055
|
+
/(^|\/)inject(\/|$)/, // every session's inject dir, any file in it
|
|
2056
|
+
/(^|\/)\.done(\/|$)/, // the bridge's accepted wakes
|
|
2057
|
+
/(^|\/)\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d{4,}(--cmd-[^/]*)?\.md(\.tmp-[^/]*)?$/, // a wake file
|
|
2058
|
+
];
|
|
2059
|
+
/** Is this path one of the wake-delivery files? Exported so both write
|
|
2060
|
+
* boundaries ask ONE question. */
|
|
2061
|
+
export function isDeliverySurface(path) {
|
|
2062
|
+
const p = protectedPathSpelling(path);
|
|
2063
|
+
if (p.length === 0)
|
|
2064
|
+
return false;
|
|
2065
|
+
return DELIVERY_SURFACE_RE.some((re) => re.test(p));
|
|
2066
|
+
}
|
|
2067
|
+
/** The full set a session may not write, from ONE definition: git's own config
|
|
2068
|
+
* and hook surface, this system's policy surface, the memory-scope files its
|
|
2069
|
+
* own recall reads and the wake-delivery files. Both the write/edit tool veto
|
|
2070
|
+
* (`checkWritePath`) and the shell-redirection / sed scan
|
|
2071
|
+
* (`checkProtectedWrites`) refuse exactly this, so they cannot drift. */
|
|
2072
|
+
export function isProtectedWriteTarget(path) {
|
|
2073
|
+
return (isGitConfigSurface(path) ||
|
|
2074
|
+
isPolicySurface(path) ||
|
|
2075
|
+
isMemoryScopeSurface(path) ||
|
|
2076
|
+
isDeliverySurface(path));
|
|
2077
|
+
}
|
|
2078
|
+
/** Veto reason for a wake-delivery write target. */
|
|
2079
|
+
function deliverySurfaceWriteBlock(path) {
|
|
2080
|
+
return block(`exec policy: '${path}' is a wake-delivery file (an inject dir, the bridge's .done/ or a wake file) — the daemon writes wakes there and reads their delivery to decide whose turn is running. A session never writes them.`);
|
|
2081
|
+
}
|
|
2082
|
+
/** Veto reason for a memory-scope write target. */
|
|
2083
|
+
function memoryScopeWriteBlock(path) {
|
|
2084
|
+
return block(`exec policy: '${path}' is a memory-scope file the daemon writes for this session's recall (the recall hint or the retired list) — writing it would widen what this session's own memory recall may reach. The reviewer reads memory; it never sets its own scope.`);
|
|
2085
|
+
}
|
|
2086
|
+
/** Veto reason for a policy-surface write target. */
|
|
2087
|
+
function policySurfaceWriteBlock(path) {
|
|
2088
|
+
return block(`exec policy: '${path}' is part of this system's own policy surface (config.toml, scripts/, dist/, extensions/, prompts/agents/seeds, .pi/) — writing it reconfigures the posting guards, the mutation gate or the compiled boundary itself. The reviewer reads code; it never edits its own controls.`);
|
|
2089
|
+
}
|
|
2090
|
+
/** Whichever refusal fits the target — git's config surface and the memory
|
|
2091
|
+
* scope keep their own, more specific messages. */
|
|
2092
|
+
function protectedWriteBlock(path) {
|
|
2093
|
+
if (isGitConfigSurface(path))
|
|
2094
|
+
return gitConfigWriteBlock(path);
|
|
2095
|
+
if (isMemoryScopeSurface(path))
|
|
2096
|
+
return memoryScopeWriteBlock(path);
|
|
2097
|
+
if (isDeliverySurface(path))
|
|
2098
|
+
return deliverySurfaceWriteBlock(path);
|
|
2099
|
+
return policySurfaceWriteBlock(path);
|
|
2100
|
+
}
|
|
2101
|
+
function gitConfigWriteBlock(path) {
|
|
2102
|
+
return block(`exec policy: '${path}' is part of git's configuration surface — writing it makes an ORDINARY git command (a plain 'git diff') execute a program of the writer's choosing. The reviewer reads code; it never configures git.`);
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* A redirection target is EXPANDED by bash before the file is opened:
|
|
2106
|
+
* pathname expansion (`*`, `?`, `[…]`) turns a pattern matching one existing
|
|
2107
|
+
* file into that file, and a sequence brace with one element (`{s..s}`) is one
|
|
2108
|
+
* word — `> recall-hint?/pr-1.json`, `> retired-uri[s].json`,
|
|
2109
|
+
* `> retired-uris.js*` and `> recall-hint{s..s}/…` each overwrite the real
|
|
2110
|
+
* file (VERIFIED), while a literal name check sees none of them. So a target
|
|
2111
|
+
* carrying either syntax is judged by every protected NAME it could expand
|
|
2112
|
+
* onto: each brace group is widened to `*`, each pattern component is replaced
|
|
2113
|
+
* by each protected component name it matches (`globToRegex`), and every
|
|
2114
|
+
* resulting path is put to the same `isProtectedWriteTarget` — for every
|
|
2115
|
+
* set, git's config surface (`.gi?/config`) and the policy surface
|
|
2116
|
+
* (`config.tom?`) included. A pattern no protected name can satisfy
|
|
2117
|
+
* (`> /tmp/out-*.txt`) is left alone.
|
|
2118
|
+
*/
|
|
2119
|
+
const BRACE_GROUP_RE = /\{[^{}]*(?:,|\.\.)[^{}]*\}/g;
|
|
2120
|
+
/** The component names the protected sets are spelled with: what a pattern
|
|
2121
|
+
* component is tried against. One representative temp name stands for the
|
|
2122
|
+
* retired list's `.tmp-<pid>`; wake patterns are judged as a filename
|
|
2123
|
+
* family by possibleWakePattern below. */
|
|
2124
|
+
const PROTECTED_COMPONENT_NAMES = [
|
|
2125
|
+
...Object.values(GIT_CONFIG_NAMES),
|
|
2126
|
+
...POLICY_SURFACE_FILES,
|
|
2127
|
+
...DYNAMIC_POLICY_FILES,
|
|
2128
|
+
...POLICY_SURFACE_DIRS,
|
|
2129
|
+
...MEMORY_SCOPE_SURFACE.map((p) => p.replace(/\/$/, "")),
|
|
2130
|
+
`${MEMORY_SCOPE_SURFACE[1]}.tmp-1`,
|
|
2131
|
+
...DELIVERY_SURFACE.filter((p) => p.endsWith("/")).map((p) => p.slice(0, -1)),
|
|
2132
|
+
];
|
|
2133
|
+
/** Past this many candidate paths a pattern is refused without enumerating
|
|
2134
|
+
* the rest — a target that broad is no reviewer's write. */
|
|
2135
|
+
const PATTERN_CANDIDATE_CAP = 512;
|
|
2136
|
+
/** A conservative intersection with wake filenames. Before the first glob
|
|
2137
|
+
* metacharacter the literal prefix must fit a timestamp/sequence; after an
|
|
2138
|
+
* optional command marker any suffix may be part of its id. The remaining
|
|
2139
|
+
* pattern is deliberately unconstrained, so uncertainty refuses a write. */
|
|
2140
|
+
function possibleWakePattern(part) {
|
|
2141
|
+
if (!GLOB_CHARS.test(part))
|
|
2142
|
+
return false;
|
|
2143
|
+
const prefix = part.split(/[*?[]/, 1)[0];
|
|
2144
|
+
const stem = "0000-00-00T00-00-00-000Z-0000";
|
|
2145
|
+
for (let i = 0; i < Math.min(prefix.length, stem.length); i++) {
|
|
2146
|
+
if (stem[i] === "0" ? !/[0-9]/.test(prefix[i]) : prefix[i] !== stem[i])
|
|
2147
|
+
return false;
|
|
2148
|
+
}
|
|
2149
|
+
if (prefix.length <= stem.length)
|
|
2150
|
+
return true;
|
|
2151
|
+
const rest = prefix.slice(stem.length).replace(/^\d*/, "");
|
|
2152
|
+
if ("--cmd-".startsWith(rest) || rest.startsWith("--cmd-"))
|
|
2153
|
+
return true;
|
|
2154
|
+
if (".md".startsWith(rest))
|
|
2155
|
+
return true;
|
|
2156
|
+
if (!rest.startsWith(".md"))
|
|
2157
|
+
return false;
|
|
2158
|
+
const tail = rest.slice(3);
|
|
2159
|
+
return ".tmp-".startsWith(tail) || tail.startsWith(".tmp-");
|
|
2160
|
+
}
|
|
2161
|
+
/** The protected path a redirection-target PATTERN can expand onto, or null
|
|
2162
|
+
* (also null for a target with no pattern syntax at all). */
|
|
2163
|
+
export function protectedPatternHit(target) {
|
|
2164
|
+
const widened = target.replace(BRACE_GROUP_RE, "*");
|
|
2165
|
+
if (!GLOB_CHARS.test(widened))
|
|
2166
|
+
return null;
|
|
2167
|
+
let paths = [""];
|
|
2168
|
+
const parts = widened.split("/");
|
|
2169
|
+
if (possibleWakePattern(parts[parts.length - 1]))
|
|
2170
|
+
return target;
|
|
2171
|
+
for (const [k, part] of parts.entries()) {
|
|
2172
|
+
const options = [part];
|
|
2173
|
+
if (GLOB_CHARS.test(part)) {
|
|
2174
|
+
const re = globToRegex(part);
|
|
2175
|
+
for (const name of PROTECTED_COMPONENT_NAMES)
|
|
2176
|
+
if (re.test(name))
|
|
2177
|
+
options.push(name);
|
|
2178
|
+
}
|
|
2179
|
+
const next = [];
|
|
2180
|
+
for (const p of paths)
|
|
2181
|
+
for (const o of options)
|
|
2182
|
+
next.push(k === 0 ? o : `${p}/${o}`);
|
|
2183
|
+
if (next.length > PATTERN_CANDIDATE_CAP)
|
|
2184
|
+
return target;
|
|
2185
|
+
paths = next;
|
|
2186
|
+
}
|
|
2187
|
+
return paths.find((p) => isProtectedWriteTarget(p)) ?? null;
|
|
2188
|
+
}
|
|
2189
|
+
/** One redirection target (dequoted), judged literally and as a pattern. */
|
|
2190
|
+
function judgeRedirectTarget(target) {
|
|
2191
|
+
const pattern = target.replace(BRACE_GROUP_RE, "*").split("/").at(-1);
|
|
2192
|
+
if (possibleWakePattern(pattern))
|
|
2193
|
+
return deliverySurfaceWriteBlock(target);
|
|
2194
|
+
if (isProtectedWriteTarget(target))
|
|
2195
|
+
return protectedWriteBlock(target);
|
|
2196
|
+
const hit = protectedPatternHit(target);
|
|
2197
|
+
if (hit === null)
|
|
2198
|
+
return { allowed: true };
|
|
2199
|
+
if (hit === target)
|
|
2200
|
+
return block(`exec policy: '${target}' is a pattern too broad to judge — bash expands it before it opens the file, and it may name a protected file. Name the file you write.`);
|
|
2201
|
+
const literal = protectedWriteBlock(hit);
|
|
2202
|
+
return block((literal.reason ?? "").replace(`'${hit}'`, `'${target}' (a pattern bash expands before it opens the file — it can name '${hit}')`));
|
|
2203
|
+
}
|
|
2204
|
+
/** Operator token shapes AFTER `normalizeRedirections`: `>`/`>>`, optionally
|
|
2205
|
+
* fd-prefixed, bare or with the target attached. */
|
|
2206
|
+
const REDIRECT_OP_RE = /^\d*>>?$/;
|
|
2207
|
+
const REDIRECT_OP_ATTACHED_RE = /^\d*>>?(.+)$/;
|
|
2208
|
+
/** A shell word with its quoting removed. bash opens `con"fig".toml`,
|
|
2209
|
+
* `config\.toml` and `config.toml` as the SAME file, so a write target is
|
|
2210
|
+
* judged by the name bash would open, not by its spelling (VERIFIED writing). */
|
|
2211
|
+
function dequote(word) {
|
|
2212
|
+
return word.replace(/["'\\]/g, "");
|
|
2213
|
+
}
|
|
2214
|
+
/** Whether `text` opens ANSI-C (`$'…'`) or locale (`$"…"`) quoting — forms
|
|
2215
|
+
* `dequote` does not decode. Only a `$` OUTSIDE quotes opens one: inside
|
|
2216
|
+
* single or double quotes `$'`/`$"` is literal (`--jq 'test("x$")'`).
|
|
2217
|
+
* Exported because `src/usage.ts` audits the same forms. */
|
|
2218
|
+
export function opensUnmodelledQuoting(text) {
|
|
2219
|
+
let quote = null;
|
|
2220
|
+
for (let i = 0; i < text.length; i++) {
|
|
2221
|
+
const c = text[i];
|
|
2222
|
+
if (quote === "'") {
|
|
2223
|
+
if (c === "'")
|
|
2224
|
+
quote = null;
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
if (c === "\\") {
|
|
2228
|
+
i++;
|
|
2229
|
+
continue;
|
|
2230
|
+
}
|
|
2231
|
+
if (quote === '"') {
|
|
2232
|
+
if (c === '"')
|
|
2233
|
+
quote = null;
|
|
2234
|
+
continue;
|
|
2235
|
+
}
|
|
2236
|
+
if (c === "$" && (text[i + 1] === "'" || text[i + 1] === '"'))
|
|
2237
|
+
return true;
|
|
2238
|
+
if (c === "'" || c === '"')
|
|
2239
|
+
quote = c;
|
|
2240
|
+
}
|
|
2241
|
+
return false;
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Every spelling of an OUTPUT redirection, rewritten onto the canonical `>` /
|
|
2245
|
+
* `>>` before anything else reads the command, so the write-target scan and
|
|
2246
|
+
* the segment splitter both see one form. Exported because `src/usage.ts`
|
|
2247
|
+
* borrows it the way it borrows `splitShellSegments` — the TOKENISER, not the
|
|
2248
|
+
* policy. Bash has more of these than `>`:
|
|
2249
|
+
* · fd-dups (`2>&1`, `>&2`, `1>&-`) name no file and are dropped;
|
|
2250
|
+
* · `>&file`, `&>file`, `&>>file` write stdout+stderr to a FILE;
|
|
2251
|
+
* · `{v}>file` opens the file on a descriptor bash picks and stores in `v`;
|
|
2252
|
+
* · `<>file` opens read-write — `printf x 1<>config.toml` OVERWRITES it;
|
|
2253
|
+
* · `>|file` overrides noclobber.
|
|
2254
|
+
* All five VERIFIED writing the target; a scan that knew only `>` and `>>`
|
|
2255
|
+
* would miss each.
|
|
2256
|
+
*/
|
|
2257
|
+
export function normalizeRedirections(cmd) {
|
|
2258
|
+
return cmd
|
|
2259
|
+
.replace(/(?:\{[A-Za-z_][A-Za-z0-9_]*\}|\d*)>&\s*(?:\d+|-)(?=$|[\s;&|)])/g, " ") // fd-dup: names no file
|
|
2260
|
+
.replace(/(?:\{[A-Za-z_][A-Za-z0-9_]*\}|\d*)>&/g, ">") // >&file
|
|
2261
|
+
.replace(/&>/g, ">") // &>file, &>>file
|
|
2262
|
+
.replace(/\{[A-Za-z_][A-Za-z0-9_]*\}(>>?|<>)/g, "$1") // {v}>file
|
|
2263
|
+
.replace(/(\d*)<>/g, "$1>") // read-write open
|
|
2264
|
+
.replace(/(>>?)\|/g, "$1"); // noclobber override
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Shell redirections and `sed -i` whose target is a PROTECTED write surface —
|
|
2268
|
+
* git's config/hook surface, this system's own policy surface (config.toml,
|
|
2269
|
+
* scripts/, dist/, extensions/, prompts/agents/seeds, .pi/, the pi context
|
|
2270
|
+
* files) or the memory-scope files a session's recall reads.
|
|
2271
|
+
*
|
|
2272
|
+
* The segment splitter deliberately skips redirection remnants when hunting for
|
|
2273
|
+
* the program token, so `cat evil > config.toml` and `printf '…' > .git/config`
|
|
2274
|
+
* both vet as an allowlisted program (`cat`/`printf`) and pass the per-segment
|
|
2275
|
+
* check. That is right for finding the PROGRAM and wrong for finding the TARGET,
|
|
2276
|
+
* so the target is asked about separately, over the raw command — the same shape
|
|
2277
|
+
* `checkSecretPaths` already uses for the root-credential paths, and for the
|
|
2278
|
+
* same reason: the path, not the program, is what distinguishes it. This is one
|
|
2279
|
+
* of the TWO boundaries `isProtectedWriteTarget` binds; the other is the
|
|
2280
|
+
* write/edit tool veto in `checkWritePath`, and both read the same predicate so
|
|
2281
|
+
* they cannot drift apart.
|
|
2282
|
+
*
|
|
2283
|
+
* `tee`, `cp`, `mv` and `dd` are the other ways to write a named file, and they
|
|
2284
|
+
* need no case here: none is on `ALLOWED`, so `checkProgram` refuses them
|
|
2285
|
+
* outright whatever their target. `sort -o`, `find -fprintf` and `awk` are
|
|
2286
|
+
* refused whole (reader-mode rules / off the allowlist), so they cannot reach a
|
|
2287
|
+
* target either. `sed -i` is the one allowlisted writer that names its target as
|
|
2288
|
+
* a plain argument rather than after a `>`; the reader-mode rules already refuse
|
|
2289
|
+
* `sed -i` outright, so this scan is defense in depth for it. The
|
|
2290
|
+
* redirection itself has more spellings than `>`/`>>` — see
|
|
2291
|
+
* `normalizeRedirections`, which folds all of them onto those two first.
|
|
2292
|
+
*/
|
|
2293
|
+
export function checkProtectedWrites(cmd) {
|
|
2294
|
+
const tokens = normalizeRedirections(cmd)
|
|
2295
|
+
.split(/\s+/)
|
|
2296
|
+
.filter((t) => t.length > 0);
|
|
2297
|
+
// `sed -i` / `sed -i.bak` / `sed -ne … -i` — the one allowlisted program that
|
|
2298
|
+
// rewrites a file named as a plain argument. When it is in play EVERY token
|
|
2299
|
+
// is a candidate target; being over-broad here costs a refusal on a command
|
|
2300
|
+
// that also mentions a protected path, which no legitimate read does.
|
|
2301
|
+
const sedInPlace = tokens.some((t) => /(^|\/)sed$/.test(t)) &&
|
|
2302
|
+
tokens.some((t) => /^-[a-zA-Z]*i([a-zA-Z]*)?([.'"].*)?$/.test(t) ||
|
|
2303
|
+
t === "--in-place" ||
|
|
2304
|
+
t.startsWith("--in-place="));
|
|
2305
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
2306
|
+
const t = tokens[i];
|
|
2307
|
+
// `> path`, `>> path` — target is the next token. Tested BEFORE the
|
|
2308
|
+
// attached form, which would otherwise swallow a bare `>>` by reading its
|
|
2309
|
+
// second `>` as the target.
|
|
2310
|
+
if (REDIRECT_OP_RE.test(t)) {
|
|
2311
|
+
const target = tokens[i + 1];
|
|
2312
|
+
if (target !== undefined && isProtectedWriteTarget(dequote(target)))
|
|
2313
|
+
return protectedWriteBlock(dequote(target));
|
|
2314
|
+
continue;
|
|
2315
|
+
}
|
|
2316
|
+
// `>path`, `>>path`, `2>path` — target attached to the operator.
|
|
2317
|
+
const attached = REDIRECT_OP_ATTACHED_RE.exec(t);
|
|
2318
|
+
if (attached) {
|
|
2319
|
+
const target = dequote(attached[1]);
|
|
2320
|
+
if (isProtectedWriteTarget(target))
|
|
2321
|
+
return protectedWriteBlock(target);
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
if (sedInPlace && isProtectedWriteTarget(dequote(t)))
|
|
2325
|
+
return protectedWriteBlock(dequote(t));
|
|
2326
|
+
}
|
|
2327
|
+
// The whitespace scan above is quote-blind on purpose (over-broad: a quoted
|
|
2328
|
+
// `> config.toml` inside a pattern is refused too) and therefore cannot see
|
|
2329
|
+
// two things bash does: an operator GLUED to the word before it
|
|
2330
|
+
// (`printf x>retired-uris.json` — `x>` is no token it knows), and
|
|
2331
|
+
// a target bash EXPANDS (`> retired-uri[s].json`). Both are judged here,
|
|
2332
|
+
// over the redirections bash would actually perform — `redirectTargets`
|
|
2333
|
+
// reads quotes, so a `>` inside a quoted grep pattern is no write and its
|
|
2334
|
+
// `.*` is never mistaken for a pattern target.
|
|
2335
|
+
for (const target of redirectTargets(normalizeRedirections(cmd))) {
|
|
2336
|
+
const res = judgeRedirectTarget(target);
|
|
2337
|
+
if (!res.allowed)
|
|
2338
|
+
return res;
|
|
2339
|
+
}
|
|
2340
|
+
return { allowed: true };
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* The target of every UNQUOTED output redirection in a command (after
|
|
2344
|
+
* `normalizeRedirections` has folded the spellings onto `>`/`>>`), each with
|
|
2345
|
+
* its quoting removed as bash removes it — `$'…'` and `$"…"` included. A
|
|
2346
|
+
* `>` inside single or double quotes, or escaped, is text, not an operator;
|
|
2347
|
+
* one glued to the word before it (`x>file`) is an operator. Process
|
|
2348
|
+
* substitution (`>(…)`) names no file and yields nothing. Exported because
|
|
2349
|
+
* `src/usage.ts` borrows it the way it borrows `splitShellSegments` — the
|
|
2350
|
+
* TOKENISER, not the policy.
|
|
2351
|
+
*/
|
|
2352
|
+
export function redirectTargets(cmd) {
|
|
2353
|
+
const out = [];
|
|
2354
|
+
let q = null;
|
|
2355
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
2356
|
+
const c = cmd[i];
|
|
2357
|
+
if (q === "'") {
|
|
2358
|
+
if (c === "'")
|
|
2359
|
+
q = null;
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
if (c === "\\") {
|
|
2363
|
+
i++;
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
if (q === '"') {
|
|
2367
|
+
if (c === '"')
|
|
2368
|
+
q = null;
|
|
2369
|
+
continue;
|
|
2370
|
+
}
|
|
2371
|
+
if (c === "'" || c === '"') {
|
|
2372
|
+
q = c;
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
if (c !== ">")
|
|
2376
|
+
continue;
|
|
2377
|
+
let j = i + 1;
|
|
2378
|
+
if (cmd[j] === ">")
|
|
2379
|
+
j++;
|
|
2380
|
+
while (j < cmd.length && (cmd[j] === " " || cmd[j] === "\t"))
|
|
2381
|
+
j++;
|
|
2382
|
+
// One shell word: up to the first unquoted blank or metacharacter.
|
|
2383
|
+
const start = j;
|
|
2384
|
+
let wq = null;
|
|
2385
|
+
for (; j < cmd.length; j++) {
|
|
2386
|
+
const d = cmd[j];
|
|
2387
|
+
if (wq !== null) {
|
|
2388
|
+
if (d === wq)
|
|
2389
|
+
wq = null;
|
|
2390
|
+
else if (wq === '"' && d === "\\")
|
|
2391
|
+
j++;
|
|
2392
|
+
continue;
|
|
2393
|
+
}
|
|
2394
|
+
if (d === "\\") {
|
|
2395
|
+
j++;
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
if (d === "'" || d === '"') {
|
|
2399
|
+
wq = d;
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
if (/[\s;&|()<>]/.test(d))
|
|
2403
|
+
break;
|
|
2404
|
+
}
|
|
2405
|
+
const raw = cmd.slice(start, Math.min(j, cmd.length));
|
|
2406
|
+
if (raw.length > 0)
|
|
2407
|
+
out.push(dequoteShellWord(raw.replace(/\$"/g, '"')));
|
|
2408
|
+
i = j - 1;
|
|
2409
|
+
}
|
|
2410
|
+
return out;
|
|
2411
|
+
}
|
|
2412
|
+
/** Pure policy check over a full bash command string: split into segments
|
|
2413
|
+
* (quote-aware) and vet each segment's first program token. */
|
|
2414
|
+
export function checkBashCommand(cmd, depth, cwd, policy) {
|
|
2415
|
+
const normalized = normalizeRedirections(cmd);
|
|
2416
|
+
const scriptCwd = scriptCwdForCommand(normalized, cwd, policy);
|
|
2417
|
+
// Checked over the WHOLE command, before the per-segment program check: the
|
|
2418
|
+
// programs that would read a root credential (cat, grep, jq…) are all
|
|
2419
|
+
// legitimately allowed, so the path is the only thing that distinguishes it.
|
|
2420
|
+
const secret = checkSecretPaths(normalized, { ...policy.secrets, cwd: cwd ?? policy.cwd });
|
|
2421
|
+
if (!secret.allowed)
|
|
2422
|
+
return secret;
|
|
2423
|
+
// Same reason again: `/proc/<pid>/environ` is read by `cat`.
|
|
2424
|
+
const proc = checkProcSecrets(normalized, cwd ?? policy.cwd);
|
|
2425
|
+
if (!proc.allowed)
|
|
2426
|
+
return proc;
|
|
2427
|
+
// Same shape, same reason: a PROTECTED write surface
|
|
2428
|
+
// (`isProtectedWriteTarget`). A redirection target is not a program token,
|
|
2429
|
+
// so the per-segment walk below never sees it.
|
|
2430
|
+
const protectedWrite = checkProtectedWrites(normalized);
|
|
2431
|
+
if (!protectedWrite.allowed)
|
|
2432
|
+
return protectedWrite;
|
|
2433
|
+
for (const segment of splitShellSegments(normalized)) {
|
|
2434
|
+
const tokens = segment
|
|
2435
|
+
.trim()
|
|
2436
|
+
.split(/\s+/)
|
|
2437
|
+
.filter((t) => t.length > 0);
|
|
2438
|
+
if (tokens.length === 0)
|
|
2439
|
+
continue;
|
|
2440
|
+
const res = checkSegment(segment, tokens, depth, scriptCwd, policy);
|
|
2441
|
+
if (!res.allowed)
|
|
2442
|
+
return res;
|
|
2443
|
+
}
|
|
2444
|
+
return { allowed: true };
|
|
2445
|
+
}
|
|
2446
|
+
/** Regex alternatives matching every prefix of `name` at least `min` long. */
|
|
2447
|
+
function longOptionPrefixes(name, min) {
|
|
2448
|
+
const out = [];
|
|
2449
|
+
for (let n = min; n <= name.length; n++)
|
|
2450
|
+
out.push(name.slice(0, n));
|
|
2451
|
+
return out.join("|");
|
|
2452
|
+
}
|
|
2453
|
+
const READER_MODE_RULES = {
|
|
2454
|
+
// -exec/-ok run a command; -delete removes files; the -fprint family writes
|
|
2455
|
+
// an arbitrary path (the `-fprintf` bypass above). find's primaries do not
|
|
2456
|
+
// cluster, so exact names are the right shape here.
|
|
2457
|
+
find: {
|
|
2458
|
+
flags: /^-(?:exec|execdir|ok|okdir|delete|fprintf|fprint|fprint0|fls)$/,
|
|
2459
|
+
what: "runs a command or writes a file",
|
|
2460
|
+
},
|
|
2461
|
+
// --pre runs a program on every file searched; --hostname-bin runs one to
|
|
2462
|
+
// name the host in hyperlinks. rg's long options do not abbreviate.
|
|
2463
|
+
rg: {
|
|
2464
|
+
flags: /^--(?:pre|hostname-bin)(?:=|$)/,
|
|
2465
|
+
what: "runs a command",
|
|
2466
|
+
},
|
|
2467
|
+
// -o/--output writes; --compress-program names a program sort execs when it
|
|
2468
|
+
// spills to temp files (refused on documented behaviour, not on a probe —
|
|
2469
|
+
// sort does not spill on a small input).
|
|
2470
|
+
// `o` is unambiguous in sort's short-option set, so a cluster carrying it is
|
|
2471
|
+
// an output redirect.
|
|
2472
|
+
sort: {
|
|
2473
|
+
// getopt_long accepts any unique prefix of a long option (`--out`, `--com`).
|
|
2474
|
+
flags: new RegExp(`^(?:--(?:${longOptionPrefixes("output", 1)}|${longOptionPrefixes("compress-program", 2)})(?:=|$)|-(?!-)[a-zA-Z]*o)`),
|
|
2475
|
+
what: "writes a file or names a program to run",
|
|
2476
|
+
},
|
|
2477
|
+
// sed is not here: `checkSedInvocation` parses it, which no pattern can match.
|
|
2478
|
+
// There is deliberately no `awk` entry: awk's exec and write forms take
|
|
2479
|
+
// arbitrary EXPRESSIONS (`print "cmd" | c`), so no rule here can gate it.
|
|
2480
|
+
// awk is off ALLOWED instead; see the comment there.
|
|
2481
|
+
};
|
|
2482
|
+
/**
|
|
2483
|
+
* sed, read as sed reads it. The regex rules above match spellings; sed's
|
|
2484
|
+
* script language is small enough to PARSE, so the decision is made on the
|
|
2485
|
+
* parse: every option is classified, every script is split into commands, and
|
|
2486
|
+
* the guard allows only an invocation whose every part it understood and none
|
|
2487
|
+
* of which writes, reads another file or runs a command. Fails closed: an
|
|
2488
|
+
* option it does not know, an abbreviation it cannot resolve to one safe
|
|
2489
|
+
* option, a script it cannot finish parsing, a script it cannot see (a
|
|
2490
|
+
* `$`-expansion or a backtick in an argument; `xargs`, which can append
|
|
2491
|
+
* options and scripts from stdin) are all refused.
|
|
2492
|
+
*
|
|
2493
|
+
* What it refuses inside a script: the `e`, `w`, `W`, `r` and `R` commands
|
|
2494
|
+
* (run a command, write a file, read a file into the stream), at any address
|
|
2495
|
+
* (`/re/`, `\cREc`, a number, `$`, `first~step`, `addr,+N`, negated with `!`);
|
|
2496
|
+
* and an `s` command whose flags carry `e` (run the pattern space as a
|
|
2497
|
+
* command) or `w` (write) — with ANY delimiter, flags in any order.
|
|
2498
|
+
*/
|
|
2499
|
+
export function checkSedInvocation(words) {
|
|
2500
|
+
const scripts = [];
|
|
2501
|
+
let positional = null;
|
|
2502
|
+
let explicit = false;
|
|
2503
|
+
// GNU getopt_long accepts any unambiguous prefix of a long option.
|
|
2504
|
+
const LONG = [
|
|
2505
|
+
["expression", "script"],
|
|
2506
|
+
["file", "refuse"],
|
|
2507
|
+
["in-place", "refuse"],
|
|
2508
|
+
["line-length", "value"],
|
|
2509
|
+
["null-data", "flag"],
|
|
2510
|
+
["zero-terminated", "flag"],
|
|
2511
|
+
["quiet", "flag"],
|
|
2512
|
+
["silent", "flag"],
|
|
2513
|
+
["regexp-extended", "flag"],
|
|
2514
|
+
["separate", "flag"],
|
|
2515
|
+
["sandbox", "flag"],
|
|
2516
|
+
["debug", "flag"],
|
|
2517
|
+
["posix", "flag"],
|
|
2518
|
+
["unbuffered", "flag"],
|
|
2519
|
+
["follow-symlinks", "flag"],
|
|
2520
|
+
["binary", "flag"],
|
|
2521
|
+
["help", "flag"],
|
|
2522
|
+
["version", "flag"],
|
|
2523
|
+
];
|
|
2524
|
+
for (let i = 0; i < words.length; i++) {
|
|
2525
|
+
const w = words[i];
|
|
2526
|
+
if (w === "--") {
|
|
2527
|
+
for (const rest of words.slice(i + 1))
|
|
2528
|
+
if (positional === null && !explicit)
|
|
2529
|
+
positional = rest;
|
|
2530
|
+
break;
|
|
2531
|
+
}
|
|
2532
|
+
if (w.startsWith("--")) {
|
|
2533
|
+
const [name, value] = w.slice(2).split(/=(.*)/s);
|
|
2534
|
+
const hits = LONG.filter(([n]) => n.startsWith(name));
|
|
2535
|
+
const exact = LONG.find(([n]) => n === name);
|
|
2536
|
+
const hit = exact ?? (hits.length === 1 ? hits[0] : undefined);
|
|
2537
|
+
if (name === "" || hit === undefined)
|
|
2538
|
+
return `an option the guard cannot classify ('${w}')`;
|
|
2539
|
+
const [full, kind] = hit;
|
|
2540
|
+
if (kind === "refuse")
|
|
2541
|
+
return `--${full}, which reads a script file or rewrites files in place`;
|
|
2542
|
+
if (kind === "script") {
|
|
2543
|
+
const script = value ?? words[++i];
|
|
2544
|
+
if (script === undefined)
|
|
2545
|
+
return `--${full} with no script`;
|
|
2546
|
+
scripts.push(script);
|
|
2547
|
+
explicit = true;
|
|
2548
|
+
}
|
|
2549
|
+
else if (kind === "value" && value === undefined)
|
|
2550
|
+
i++;
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
if (w.startsWith("-") && w.length > 1) {
|
|
2554
|
+
for (let j = 1; j < w.length; j++) {
|
|
2555
|
+
const c = w[j];
|
|
2556
|
+
if ("nrEsuz".includes(c))
|
|
2557
|
+
continue;
|
|
2558
|
+
if (c === "e" || c === "l") {
|
|
2559
|
+
const rest = w.slice(j + 1);
|
|
2560
|
+
const arg = rest.length > 0 ? rest : words[++i];
|
|
2561
|
+
if (arg === undefined)
|
|
2562
|
+
return `-${c} with no argument`;
|
|
2563
|
+
if (c === "e") {
|
|
2564
|
+
scripts.push(arg);
|
|
2565
|
+
explicit = true;
|
|
2566
|
+
}
|
|
2567
|
+
break;
|
|
2568
|
+
}
|
|
2569
|
+
if (c === "f" || c === "i")
|
|
2570
|
+
return `-${c}, which reads a script file or rewrites files in place`;
|
|
2571
|
+
return `an option the guard cannot classify (-${c})`;
|
|
2572
|
+
}
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2575
|
+
if (!explicit && positional === null)
|
|
2576
|
+
positional = w;
|
|
2577
|
+
}
|
|
2578
|
+
if (!explicit) {
|
|
2579
|
+
if (positional === null)
|
|
2580
|
+
return null; // sed with no script: an error, not a risk
|
|
2581
|
+
scripts.push(positional);
|
|
2582
|
+
}
|
|
2583
|
+
for (const script of scripts) {
|
|
2584
|
+
const why = sedScriptDanger(script);
|
|
2585
|
+
if (why !== null)
|
|
2586
|
+
return why;
|
|
2587
|
+
}
|
|
2588
|
+
return null;
|
|
2589
|
+
}
|
|
2590
|
+
/** The first command in a sed script that writes, reads a file or runs a
|
|
2591
|
+
* command — or that the parser cannot classify — or null when every command
|
|
2592
|
+
* is a plain stream edit. */
|
|
2593
|
+
export function sedScriptDanger(script) {
|
|
2594
|
+
const s = script;
|
|
2595
|
+
let i = 0;
|
|
2596
|
+
const at = (k = i) => s[k] ?? "";
|
|
2597
|
+
const skipBlank = () => {
|
|
2598
|
+
while (i < s.length && /[ \t]/.test(at()))
|
|
2599
|
+
i++;
|
|
2600
|
+
};
|
|
2601
|
+
// A delimited part (`/re/`, `\cREc`, an s or y part): up to the next
|
|
2602
|
+
// unescaped delimiter. Brackets are not special here: a misread there only
|
|
2603
|
+
// moves where the flags are scanned from, and the flag scan runs to the end
|
|
2604
|
+
// of the command.
|
|
2605
|
+
const delimited = (d) => {
|
|
2606
|
+
while (i < s.length) {
|
|
2607
|
+
const c = at();
|
|
2608
|
+
if (c === "\\") {
|
|
2609
|
+
i += 2;
|
|
2610
|
+
continue;
|
|
2611
|
+
}
|
|
2612
|
+
if (c === "\n" && d !== "\n")
|
|
2613
|
+
return false;
|
|
2614
|
+
i++;
|
|
2615
|
+
if (c === d)
|
|
2616
|
+
return true;
|
|
2617
|
+
}
|
|
2618
|
+
return false;
|
|
2619
|
+
};
|
|
2620
|
+
// The two delimited parts of an `s` or `y` command; true when they cannot be
|
|
2621
|
+
// read (no usable delimiter, or either part unterminated).
|
|
2622
|
+
const badSedDelimiter = (d) => d === "" || d === "\\" || d === "\n" || !delimited(d) || !delimited(d);
|
|
2623
|
+
const address = () => {
|
|
2624
|
+
const c = at();
|
|
2625
|
+
if (/[0-9]/.test(c)) {
|
|
2626
|
+
while (/[0-9]/.test(at()))
|
|
2627
|
+
i++;
|
|
2628
|
+
if (at() === "~") {
|
|
2629
|
+
i++;
|
|
2630
|
+
while (/[0-9]/.test(at()))
|
|
2631
|
+
i++;
|
|
2632
|
+
}
|
|
2633
|
+
return true;
|
|
2634
|
+
}
|
|
2635
|
+
if (c === "$") {
|
|
2636
|
+
i++;
|
|
2637
|
+
return true;
|
|
2638
|
+
}
|
|
2639
|
+
if (c === "/" || c === "\\") {
|
|
2640
|
+
const d = c === "/" ? "/" : at(i + 1);
|
|
2641
|
+
i += c === "/" ? 1 : 2;
|
|
2642
|
+
if (d === "" || d === "\n" || !delimited(d))
|
|
2643
|
+
return false;
|
|
2644
|
+
while (/[IM]/.test(at()))
|
|
2645
|
+
i++;
|
|
2646
|
+
return true;
|
|
2647
|
+
}
|
|
2648
|
+
return false;
|
|
2649
|
+
};
|
|
2650
|
+
while (i < s.length) {
|
|
2651
|
+
while (i < s.length && /[ \t\n;]/.test(at()))
|
|
2652
|
+
i++;
|
|
2653
|
+
if (i >= s.length)
|
|
2654
|
+
break;
|
|
2655
|
+
if (address()) {
|
|
2656
|
+
skipBlank();
|
|
2657
|
+
if (at() === ",") {
|
|
2658
|
+
i++;
|
|
2659
|
+
skipBlank();
|
|
2660
|
+
if (at() === "+" || at() === "~") {
|
|
2661
|
+
i++;
|
|
2662
|
+
while (/[0-9]/.test(at()))
|
|
2663
|
+
i++;
|
|
2664
|
+
}
|
|
2665
|
+
else if (!address())
|
|
2666
|
+
return "an address the guard cannot read";
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
skipBlank();
|
|
2670
|
+
while (at() === "!") {
|
|
2671
|
+
i++;
|
|
2672
|
+
skipBlank();
|
|
2673
|
+
}
|
|
2674
|
+
const cmd = at();
|
|
2675
|
+
i++;
|
|
2676
|
+
switch (cmd) {
|
|
2677
|
+
case "{":
|
|
2678
|
+
case "}":
|
|
2679
|
+
case "=":
|
|
2680
|
+
case "d":
|
|
2681
|
+
case "D":
|
|
2682
|
+
case "g":
|
|
2683
|
+
case "G":
|
|
2684
|
+
case "h":
|
|
2685
|
+
case "H":
|
|
2686
|
+
case "n":
|
|
2687
|
+
case "N":
|
|
2688
|
+
case "p":
|
|
2689
|
+
case "P":
|
|
2690
|
+
case "x":
|
|
2691
|
+
case "z":
|
|
2692
|
+
case "F":
|
|
2693
|
+
break;
|
|
2694
|
+
case "l":
|
|
2695
|
+
case "L":
|
|
2696
|
+
case "q":
|
|
2697
|
+
case "Q":
|
|
2698
|
+
skipBlank();
|
|
2699
|
+
while (/[0-9]/.test(at()))
|
|
2700
|
+
i++;
|
|
2701
|
+
break;
|
|
2702
|
+
case "#":
|
|
2703
|
+
// A comment runs to the end of the line.
|
|
2704
|
+
while (i < s.length && at() !== "\n")
|
|
2705
|
+
i++;
|
|
2706
|
+
break;
|
|
2707
|
+
case ":":
|
|
2708
|
+
case "b":
|
|
2709
|
+
case "t":
|
|
2710
|
+
case "T":
|
|
2711
|
+
// A label ends at whitespace, `;`, `}` or a newline, and what follows
|
|
2712
|
+
// is the NEXT command — GNU reads `bx e cmd` as a branch, then `e`.
|
|
2713
|
+
skipBlank();
|
|
2714
|
+
while (i < s.length && !/[\s;}]/.test(at()))
|
|
2715
|
+
i++;
|
|
2716
|
+
break;
|
|
2717
|
+
case "a":
|
|
2718
|
+
case "i":
|
|
2719
|
+
case "c":
|
|
2720
|
+
// Text to the end of the line, with backslash-newline continuations.
|
|
2721
|
+
while (i < s.length && at() !== "\n")
|
|
2722
|
+
i += at() === "\\" ? 2 : 1;
|
|
2723
|
+
break;
|
|
2724
|
+
case "y": {
|
|
2725
|
+
const d = at();
|
|
2726
|
+
i++;
|
|
2727
|
+
if (badSedDelimiter(d))
|
|
2728
|
+
return "a y command the guard cannot read";
|
|
2729
|
+
break;
|
|
2730
|
+
}
|
|
2731
|
+
case "s": {
|
|
2732
|
+
const d = at();
|
|
2733
|
+
i++;
|
|
2734
|
+
if (badSedDelimiter(d))
|
|
2735
|
+
return "an s command the guard cannot read";
|
|
2736
|
+
// The flags: everything up to the end of the command. GNU's flags are
|
|
2737
|
+
// g p N i I m M e w — `e` runs the pattern space as a command, `w`
|
|
2738
|
+
// writes a file.
|
|
2739
|
+
const start = i;
|
|
2740
|
+
while (i < s.length && !/[;\n}]/.test(at()))
|
|
2741
|
+
i++;
|
|
2742
|
+
const flags = s.slice(start, i);
|
|
2743
|
+
if (/[ew]/.test(flags))
|
|
2744
|
+
return `an s command with an ${/e/.test(flags) ? "e" : "w"} flag`;
|
|
2745
|
+
break;
|
|
2746
|
+
}
|
|
2747
|
+
case "e":
|
|
2748
|
+
case "w":
|
|
2749
|
+
case "W":
|
|
2750
|
+
case "r":
|
|
2751
|
+
case "R":
|
|
2752
|
+
return `a ${cmd} command`;
|
|
2753
|
+
default:
|
|
2754
|
+
return `a command the guard cannot classify (${JSON.stringify(cmd)})`;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
return null;
|
|
2758
|
+
}
|
|
2759
|
+
/** `text` after its first `n` whitespace-separated words, verbatim — the same
|
|
2760
|
+
* words `checkBashCommand` splits a segment into. */
|
|
2761
|
+
function textAfterWords(text, n) {
|
|
2762
|
+
const word = /\S+/g;
|
|
2763
|
+
let end = 0;
|
|
2764
|
+
for (let k = 0; k < n; k++) {
|
|
2765
|
+
const m = word.exec(text);
|
|
2766
|
+
if (m === null)
|
|
2767
|
+
return "";
|
|
2768
|
+
end = m.index + m[0].length;
|
|
2769
|
+
}
|
|
2770
|
+
return text.slice(end);
|
|
2771
|
+
}
|
|
2772
|
+
/** Shell words of a sed invocation's argument text, as bash will hand them to
|
|
2773
|
+
* sed — or null when a word's value is not knowable from the text (a `$`
|
|
2774
|
+
* expansion, ANSI-C or locale quoting, a backtick, an unclosed quote). */
|
|
2775
|
+
export function sedWords(text) {
|
|
2776
|
+
const out = [];
|
|
2777
|
+
let cur = "";
|
|
2778
|
+
let inWord = false;
|
|
2779
|
+
for (let i = 0; i < text.length; i++) {
|
|
2780
|
+
const c = text[i];
|
|
2781
|
+
if (c === "'") {
|
|
2782
|
+
const end = text.indexOf("'", i + 1);
|
|
2783
|
+
if (end < 0)
|
|
2784
|
+
return null;
|
|
2785
|
+
cur += text.slice(i + 1, end);
|
|
2786
|
+
i = end;
|
|
2787
|
+
inWord = true;
|
|
2788
|
+
}
|
|
2789
|
+
else if (c === '"') {
|
|
2790
|
+
i++;
|
|
2791
|
+
for (; i < text.length && text[i] !== '"'; i++) {
|
|
2792
|
+
const d = text[i];
|
|
2793
|
+
if (d === "$" || d === "`")
|
|
2794
|
+
return null;
|
|
2795
|
+
if (d === "\\" && /["\\$`\n]/.test(text[i + 1] ?? ""))
|
|
2796
|
+
cur += text[++i];
|
|
2797
|
+
else
|
|
2798
|
+
cur += d;
|
|
2799
|
+
}
|
|
2800
|
+
if (i >= text.length)
|
|
2801
|
+
return null;
|
|
2802
|
+
inWord = true;
|
|
2803
|
+
}
|
|
2804
|
+
else if (c === "\\") {
|
|
2805
|
+
if (text[i + 1] === "\n") {
|
|
2806
|
+
i++; // a line continuation joins, it adds nothing
|
|
2807
|
+
continue;
|
|
2808
|
+
}
|
|
2809
|
+
cur += text[++i] ?? "";
|
|
2810
|
+
inWord = true;
|
|
2811
|
+
}
|
|
2812
|
+
else if (c === "$" || c === "`") {
|
|
2813
|
+
return null;
|
|
2814
|
+
}
|
|
2815
|
+
else if (/\s/.test(c)) {
|
|
2816
|
+
if (inWord)
|
|
2817
|
+
out.push(cur);
|
|
2818
|
+
cur = "";
|
|
2819
|
+
inWord = false;
|
|
2820
|
+
}
|
|
2821
|
+
else {
|
|
2822
|
+
cur += c;
|
|
2823
|
+
inWord = true;
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
if (inWord)
|
|
2827
|
+
out.push(cur);
|
|
2828
|
+
return out;
|
|
2829
|
+
}
|
|
2830
|
+
/**
|
|
2831
|
+
* Does this invocation of an allowlisted reader use one of its write/exec
|
|
2832
|
+
* modes? Exported so the reason is testable on its own.
|
|
2833
|
+
*/
|
|
2834
|
+
export function checkReaderModes(prog, args, rawArgs, rawText) {
|
|
2835
|
+
if (prog === "sed") {
|
|
2836
|
+
// From the verbatim text when there is one: a newline inside a quoted
|
|
2837
|
+
// script ends an `a`/`i`/`c` text, a comment or a label, so re-joined
|
|
2838
|
+
// tokens would hide the command after it (`'1a x⏎e'`).
|
|
2839
|
+
const words = rawText !== undefined
|
|
2840
|
+
? sedWords(rawText)
|
|
2841
|
+
: rawArgs !== undefined
|
|
2842
|
+
? sedWords(rawArgs.join(" "))
|
|
2843
|
+
: [...args];
|
|
2844
|
+
const why = words === null
|
|
2845
|
+
? "an argument the guard cannot read as words ($-expansion, $'…' quoting, a backtick, a quote cut by the command splitter)"
|
|
2846
|
+
: checkSedInvocation(words);
|
|
2847
|
+
if (why !== null) {
|
|
2848
|
+
return block(`exec policy: this 'sed' has ${why}, which writes a file or runs a command, or cannot be checked — the reviewer reads code and never writes or runs anything. Use 'sed -n' with plain p/s///g scripts.`);
|
|
2849
|
+
}
|
|
2850
|
+
return { allowed: true };
|
|
2851
|
+
}
|
|
2852
|
+
let words = rawText === undefined ? args.map(dequote) : shellWords(rawText).words;
|
|
2853
|
+
if (["find", "sort", "uniq"].includes(prog)) {
|
|
2854
|
+
// As for sed: an argument value the guard cannot see (`$flag`, a command
|
|
2855
|
+
// substitution) could be the very option these rules refuse.
|
|
2856
|
+
const text = rawText ?? rawArgs?.join(" ");
|
|
2857
|
+
const read = text === undefined ? null : sedWords(text);
|
|
2858
|
+
if (text !== undefined && read === null) {
|
|
2859
|
+
return block(`exec policy: '${prog}' has an argument the guard cannot read as words ($-expansion, a backtick, a quote cut by the command splitter), which could be an option that writes a file or runs a command — spell it out`);
|
|
2860
|
+
}
|
|
2861
|
+
if (read !== null)
|
|
2862
|
+
words = read;
|
|
2863
|
+
}
|
|
2864
|
+
if (["find", "sort", "uniq"].includes(prog) &&
|
|
2865
|
+
(args.some(opensUnmodelledQuoting) ||
|
|
2866
|
+
(rawText !== undefined && opensUnmodelledQuoting(rawText)))) {
|
|
2867
|
+
return block(`exec policy: '${prog}' has quoting the guard cannot decode`);
|
|
2868
|
+
}
|
|
2869
|
+
if (prog === "uniq") {
|
|
2870
|
+
let operands = 0;
|
|
2871
|
+
let options = true;
|
|
2872
|
+
for (let i = 0; i < words.length; i++) {
|
|
2873
|
+
const a = words[i];
|
|
2874
|
+
if (options && a === "--") {
|
|
2875
|
+
options = false;
|
|
2876
|
+
continue;
|
|
2877
|
+
}
|
|
2878
|
+
if (options && a.startsWith("-") && a !== "-") {
|
|
2879
|
+
if (/^--(?:skip-fields|skip-chars|check-chars)$/.test(a))
|
|
2880
|
+
i++;
|
|
2881
|
+
else if (!a.startsWith("--")) {
|
|
2882
|
+
// A short cluster: f, s and w take the rest of the cluster as their
|
|
2883
|
+
// value, or the next word when they end it.
|
|
2884
|
+
const at = a.slice(1).search(/[fsw]/);
|
|
2885
|
+
if (at >= 0 && at === a.length - 2)
|
|
2886
|
+
i++;
|
|
2887
|
+
}
|
|
2888
|
+
continue;
|
|
2889
|
+
}
|
|
2890
|
+
operands++;
|
|
2891
|
+
}
|
|
2892
|
+
return operands > 1
|
|
2893
|
+
? block("exec policy: 'uniq' with an output operand writes a file — use only an input operand")
|
|
2894
|
+
: { allowed: true };
|
|
2895
|
+
}
|
|
2896
|
+
const rule = READER_MODE_RULES[prog];
|
|
2897
|
+
if (rule === undefined)
|
|
2898
|
+
return { allowed: true };
|
|
2899
|
+
for (const a of words) {
|
|
2900
|
+
if (a.length > 0 && a.startsWith("-") && rule.flags.test(a)) {
|
|
2901
|
+
return block(`exec policy: '${prog} ${a}' ${rule.what} — the reviewer reads code and never writes or runs anything. Use the read-only form.`);
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
return { allowed: true };
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* The write/edit tools' policy check. The write half of the two boundaries
|
|
2908
|
+
* `isProtectedWriteTarget` binds — the other is the shell redirection /
|
|
2909
|
+
* `sed -i` scan in `checkProtectedWrites`, and both read the same predicate so
|
|
2910
|
+
* a session cannot write through one what it is refused through the other.
|
|
2911
|
+
* Refuses git's config/hook surface, this system's own policy surface
|
|
2912
|
+
* (config.toml, scripts/, dist/, extensions/, prompts/agents/seeds, .pi/, the
|
|
2913
|
+
* pi context files) and the memory-scope files its recall reads (the recall
|
|
2914
|
+
* hint, the retired list); everything else a session writes — the verdict file,
|
|
2915
|
+
* the ledger, /tmp bodies — is allowed. This is not a write allowlist.
|
|
2916
|
+
*/
|
|
2917
|
+
export function checkWritePath(path) {
|
|
2918
|
+
for (const spelling of toolPathSpellings(path)) {
|
|
2919
|
+
if (isProtectedWriteTarget(spelling))
|
|
2920
|
+
return protectedWriteBlock(spelling);
|
|
2921
|
+
}
|
|
2922
|
+
return { allowed: true };
|
|
2923
|
+
}
|
|
2924
|
+
/** The spellings of a file tool's `path` argument that name a destination: as
|
|
2925
|
+
* given, and as pi opens it (unicode spaces normalised, the `@` prefix dropped,
|
|
2926
|
+
* a `file://` URL decoded). */
|
|
2927
|
+
function toolPathSpellings(path) {
|
|
2928
|
+
const out = [path];
|
|
2929
|
+
let p = path.replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
|
|
2930
|
+
if (p.startsWith("@"))
|
|
2931
|
+
p = p.slice(1);
|
|
2932
|
+
if (/^file:\/\//.test(p)) {
|
|
2933
|
+
try {
|
|
2934
|
+
p = fileURLToPath(p);
|
|
2935
|
+
}
|
|
2936
|
+
catch {
|
|
2937
|
+
// pi cannot open an undecodable URL either
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
if (p !== path)
|
|
2941
|
+
out.push(p);
|
|
2942
|
+
return out;
|
|
2943
|
+
}
|
|
2944
|
+
/** Tool names that take a file path and write it. Kept broad on purpose: a
|
|
2945
|
+
* name this list misses is a hole, and a name it carries that does not exist
|
|
2946
|
+
* costs nothing. // VERIFY-ON-PIN: pi's write-tool naming. */
|
|
2947
|
+
const WRITE_TOOLS = new Set([
|
|
2948
|
+
"write",
|
|
2949
|
+
"edit",
|
|
2950
|
+
"multi_edit",
|
|
2951
|
+
"multiedit",
|
|
2952
|
+
"str_replace",
|
|
2953
|
+
"create",
|
|
2954
|
+
"write_file",
|
|
2955
|
+
"apply_patch",
|
|
2956
|
+
]);
|
|
2957
|
+
/** Tool names that take a file or directory path and READ it: pi's
|
|
2958
|
+
* built-in `read`, and its optional `grep`/`find`/`ls` (off by default — pi
|
|
2959
|
+
* 1.0.0 `DEFAULT_TOOL_NAMES` is read/bash/edit/write — but a settings layer
|
|
2960
|
+
* can switch them on). Broad for the same reason as WRITE_TOOLS.
|
|
2961
|
+
* // VERIFY-ON-PIN: pi's read-tool naming beyond the four built-ins. */
|
|
2962
|
+
const READ_TOOLS = new Set(["read", "grep", "find", "ls", "read_file", "view", "cat"]);
|
|
2963
|
+
function policyFromConfig(config) {
|
|
2964
|
+
const dirs = new Set([CONTAINER_SECRETS_DIR, ...config.secretDirs]);
|
|
2965
|
+
return {
|
|
2966
|
+
sandboxRoot: config.sandboxRoot,
|
|
2967
|
+
cwd: config.cwd ?? config.sandboxRoot,
|
|
2968
|
+
replay: config.replay,
|
|
2969
|
+
noPost: config.noPost === true,
|
|
2970
|
+
secrets: {
|
|
2971
|
+
env: config.pathEnv ?? {},
|
|
2972
|
+
dirs: [...dirs]
|
|
2973
|
+
.filter((d) => d.startsWith("/"))
|
|
2974
|
+
.map((d) => posix.normalize(d).replace(/(.)\/+$/, "$1")),
|
|
2975
|
+
runDirs: defaultRunDirs(config.pathEnv ?? {}),
|
|
2976
|
+
},
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
/** The three env-decoded guard inputs: the sandbox root (QCR_SANDBOX_ROOT, else
|
|
2980
|
+
* the cwd), QCR_REPLAY and QCR_TAKEOVER. The one place the env is read for them. */
|
|
2981
|
+
export function guardFlagsFromEnv(env, cwd) {
|
|
2982
|
+
return {
|
|
2983
|
+
sandboxRoot: env.QCR_SANDBOX_ROOT ?? cwd,
|
|
2984
|
+
replay: env.QCR_REPLAY === "1",
|
|
2985
|
+
noPost: env.QCR_TAKEOVER === "1",
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2988
|
+
/** The config the env describes: QCR_SANDBOX_ROOT (else the cwd), QCR_REPLAY,
|
|
2989
|
+
* QCR_TAKEOVER, and the secrets dirs `defaultSecretPathContext` resolves from the same env. */
|
|
2990
|
+
export function bashGuardConfigFromEnv(env, cwd) {
|
|
2991
|
+
return {
|
|
2992
|
+
cwd,
|
|
2993
|
+
...guardFlagsFromEnv(env, cwd),
|
|
2994
|
+
secretDirs: defaultSecretPathContext(env, cwd).dirs,
|
|
2995
|
+
pathEnv: {
|
|
2996
|
+
HOME: env.HOME,
|
|
2997
|
+
XDG_CONFIG_HOME: env.XDG_CONFIG_HOME,
|
|
2998
|
+
QCR_INSTALL: env.QCR_INSTALL,
|
|
2999
|
+
QCR_IPC_SOCKET: env.QCR_IPC_SOCKET,
|
|
3000
|
+
},
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* The factory a session loads: the guard over this config. A pi loaded by
|
|
3005
|
+
* path gets it through the env shim, extensions/cli/bash-guard.ts, and an SDK
|
|
3006
|
+
* session as this extension factory. VERIFIED (pi control-surface findings §5):
|
|
3007
|
+
* a tool_call handler can return { block: true, reason } to veto the call, and
|
|
3008
|
+
* event.input is mutable. pi 1.3.0 emits `{ type: "tool_call", toolName,
|
|
3009
|
+
* toolCallId, input }` and its read/grep/find/ls tools take `path` (grep also
|
|
3010
|
+
* `glob`).
|
|
3011
|
+
* `toolName` and `input` on the event payload are seen in production (above).
|
|
3012
|
+
* // VERIFY-ON-PIN: `path`/`file_path` on a file tool's input (following
|
|
3013
|
+
* extensions/companion-guard.ts, which already vetoes writes this way).
|
|
3014
|
+
*/
|
|
3015
|
+
export function createBashGuard(config) {
|
|
3016
|
+
const policy = policyFromConfig(config);
|
|
3017
|
+
return (pi) => {
|
|
3018
|
+
pi.on("tool_call", (event, ctx) => {
|
|
3019
|
+
// `arguments` is not on pi 1.4.0's ToolCallEvent; the probe stays for a
|
|
3020
|
+
// pi that names the field that way (VERIFY-ON-PIN).
|
|
3021
|
+
const ev = event;
|
|
3022
|
+
const input = (ev.input ?? ev.arguments);
|
|
3023
|
+
// The session's own cwd: an SDK session shares its host's process.
|
|
3024
|
+
const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : policy.cwd;
|
|
3025
|
+
// The App-secret belt on every file tool, read AND write: an
|
|
3026
|
+
// `edit` that fails to match still answers with what it read.
|
|
3027
|
+
if (typeof ev.toolName === "string" &&
|
|
3028
|
+
(READ_TOOLS.has(ev.toolName) || WRITE_TOOLS.has(ev.toolName))) {
|
|
3029
|
+
for (const p of [input?.path, input?.file_path, input?.filePath, input?.glob]) {
|
|
3030
|
+
if (typeof p !== "string")
|
|
3031
|
+
continue;
|
|
3032
|
+
const res = checkReadPath(p, { ...policy.secrets, cwd });
|
|
3033
|
+
if (!res.allowed)
|
|
3034
|
+
return { block: true, reason: res.reason ?? "exec policy: secret path blocked" };
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
if (ev.toolName === "bash") {
|
|
3038
|
+
const cmd = input?.command;
|
|
3039
|
+
if (typeof cmd !== "string")
|
|
3040
|
+
return;
|
|
3041
|
+
const res = checkBashCommand(cmd, 0, cwd, policy);
|
|
3042
|
+
if (!res.allowed)
|
|
3043
|
+
return { block: true, reason: res.reason ?? "exec policy: command blocked" };
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
3046
|
+
if (typeof ev.toolName === "string" && WRITE_TOOLS.has(ev.toolName)) {
|
|
3047
|
+
const target = input?.path ?? input?.file_path ?? input?.filePath;
|
|
3048
|
+
if (typeof target !== "string")
|
|
3049
|
+
return;
|
|
3050
|
+
const res = checkWritePath(target);
|
|
3051
|
+
if (!res.allowed)
|
|
3052
|
+
return { block: true, reason: res.reason ?? "exec policy: write blocked" };
|
|
3053
|
+
}
|
|
3054
|
+
return;
|
|
3055
|
+
});
|
|
3056
|
+
};
|
|
3057
|
+
}
|
|
3058
|
+
//# sourceMappingURL=bash-guard.js.map
|