session-orchestrator 5.11.0 → 5.13.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/.claude-plugin/marketplace.json +12 -1
- package/.claude-plugin/plugin.json +4 -1
- package/.codex-plugin/agents/explorer.toml +1 -1
- package/.codex-plugin/agents/session-reviewer.toml +2 -2
- package/.codex-plugin/agents/wave-worker.toml +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +8 -0
- package/.orchestrator/policy/public-export.json +30 -0
- package/CHANGELOG.md +180 -3
- package/README.md +126 -117
- package/agents/analyst.md +7 -2
- package/agents/architect-reviewer.md +7 -2
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +9 -2
- package/agents/dialectic-deriver.md +9 -3
- package/agents/docs-writer.md +7 -2
- package/agents/eval-judge.md +7 -1
- package/agents/qa-strategist.md +7 -2
- package/agents/security-reviewer.md +9 -2
- package/agents/session-reviewer.md +18 -2
- package/agents/skill-applied-judge.md +7 -1
- package/agents/test-writer.md +9 -2
- package/agents/ui-developer.md +9 -2
- package/agents/ux-evaluator.md +6 -1
- package/commands/session.md +4 -2
- package/commands/templates-ack.md +89 -18
- package/docs/agent-authoring.md +30 -11
- package/docs/ci-setup.md +38 -2
- package/docs/codex-setup.md +33 -9
- package/docs/components.md +13 -2
- package/docs/cursor-setup.md +3 -3
- package/docs/events-schema.md +5 -4
- package/docs/evidence-candidates.md +63 -0
- package/docs/fleet-checkin-audit.md +7 -4
- package/docs/github-mirror-protection.md +150 -3
- package/docs/install.md +102 -25
- package/docs/instruction-delivery.md +1 -1
- package/docs/migration-v4.md +2 -2
- package/docs/mods.md +118 -0
- package/docs/owner-config-schema.md +1 -0
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/pm-skills-marketplace.md +4 -3
- package/docs/rule-authoring.md +24 -13
- package/docs/scope-collision-guard.md +2 -1
- package/docs/session-config-reference.md +55 -21
- package/docs/session-config-template.md +32 -24
- package/docs/sessions-ledger-repair.md +137 -0
- package/docs/vault-docs-architecture.md +1 -1
- package/hooks/_lib/hook-import-set.json +406 -73
- package/hooks/_lib/hook-root.mjs +301 -0
- package/hooks/_lib/lock-bootstrap.mjs +434 -4
- package/hooks/_lib/orchestrator-ignore.mjs +3 -0
- package/hooks/_lib/subagent-transcript.mjs +2 -2
- package/hooks/_lib/vcs-create-matcher.mjs +13 -17
- package/hooks/cwd-change-restore.mjs +5 -1
- package/hooks/enforce-commands.mjs +4 -3
- package/hooks/enforce-scope.mjs +11 -1
- package/hooks/hooks-codex.json +48 -2
- package/hooks/hooks.json +31 -31
- package/hooks/on-session-end.mjs +38 -15
- package/hooks/on-session-start.mjs +158 -59
- package/hooks/on-stop.mjs +11 -6
- package/hooks/post-bash-issue-budget-refund.mjs +6 -1
- package/hooks/post-bash-write-verify.mjs +8 -4
- package/hooks/post-edit-validate.mjs +45 -52
- package/hooks/post-subagent-discovery-validator.mjs +2 -2
- package/hooks/post-tool-batch-wave-signal.mjs +8 -4
- package/hooks/post-tool-failure-corrective-context.mjs +5 -1
- package/hooks/pre-bash-destructive-guard.mjs +220 -8
- package/hooks/pre-bash-issue-budget.mjs +6 -1
- package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
- package/hooks/pre-bash-staging-fence.mjs +258 -14
- package/hooks/pre-bash-templates-first.mjs +28 -27
- package/hooks/run-node.sh +1 -1
- package/hooks/skill-invocation-telemetry.mjs +15 -8
- package/hooks/subagent-telemetry.mjs +77 -14
- package/hooks/wave-scope-commit-guard.mjs +143 -14
- package/mods/so-hud/.claude-plugin/plugin.json +11 -0
- package/mods/so-hud/README.md +57 -0
- package/mods/so-hud/hooks/beacon-io.mjs +101 -0
- package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
- package/mods/so-hud/hooks/hooks.json +1 -0
- package/mods/so-hud/hooks/logic.ts +182 -0
- package/mods/so-hud/hooks/register.tsx +670 -0
- package/mods/so-hud/hooks/rules.ts +2998 -0
- package/mods/so-hud/tsconfig.json +8 -0
- package/mods/so-hud/types/index.d.ts +36 -0
- package/package.json +4 -3
- package/rules/_index.md +1 -1
- package/rules/always-on/ask-via-tool.md +1 -1
- package/rules/always-on/loop-and-monitor.md +17 -194
- package/rules/always-on/parallel-sessions.md +3 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +1 -1
- package/rules/opt-in-stack/security-web.md +5 -1
- package/scripts/agent-usage-census.mjs +79 -0
- package/scripts/apply-session-learnings.mjs +34 -6
- package/scripts/archive-closed-prds.mjs +21 -5
- package/scripts/autopilot.mjs +11 -5
- package/scripts/backfill-abandoned-sessions.mjs +13 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +61 -6
- package/scripts/backfill-sessions.mjs +16 -2
- package/scripts/check-package-manager.mjs +26 -8
- package/scripts/check-sessions-integrity.mjs +4 -1
- package/scripts/ci/assert-vitest-green.mjs +3 -0
- package/scripts/ci/windows-smoke.mjs +201 -0
- package/scripts/close-precheck.mjs +55 -0
- package/scripts/dialectic-deriver.mjs +20 -12
- package/scripts/emit-event.mjs +2 -2
- package/scripts/emit-session.mjs +239 -20
- package/scripts/eval-session.mjs +17 -3
- package/scripts/export-hw-learnings.mjs +29 -2
- package/scripts/fleet-checkin-audit.mjs +8 -4
- package/scripts/fleet-checkin.mjs +73 -3
- package/scripts/gc-stale-worktrees.mjs +6 -1
- package/scripts/github-export.mjs +128 -0
- package/scripts/github-protection-audit.mjs +3 -1
- package/scripts/handover-queue-check.mjs +209 -0
- package/scripts/harness-audit.mjs +6 -4
- package/scripts/lib/agent-frontmatter.mjs +57 -10
- package/scripts/lib/agent-status.mjs +125 -7
- package/scripts/lib/agent-usage-census.mjs +394 -0
- package/scripts/lib/auto-dialectic.mjs +17 -10
- package/scripts/lib/auto-dream.mjs +22 -14
- package/scripts/lib/autopilot/loop.mjs +3 -1
- package/scripts/lib/autopilot/recent-runs.mjs +4 -2
- package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
- package/scripts/lib/autopilot/telemetry.mjs +5 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/baseline-archetypes.mjs +10 -2
- package/scripts/lib/blocked-commands-policy.mjs +4 -3
- package/scripts/lib/build-live-signals.mjs +13 -9
- package/scripts/lib/ci-status-banner.mjs +329 -24
- package/scripts/lib/codex/plugin-contract.mjs +8 -0
- package/scripts/lib/cold-start-detector.mjs +5 -2
- package/scripts/lib/command-blocker.mjs +603 -43
- package/scripts/lib/common.mjs +3 -2
- package/scripts/lib/config/cross-repo.mjs +74 -7
- package/scripts/lib/config/host-paths.mjs +6 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config/section-extractor.mjs +65 -11
- package/scripts/lib/config/template-keys.mjs +153 -0
- package/scripts/lib/config/vault-integration.mjs +1 -0
- package/scripts/lib/config.mjs +3 -3
- package/scripts/lib/coordinator-snapshot.mjs +1 -1
- package/scripts/lib/discovery/triage-state.mjs +2 -1
- package/scripts/lib/dispatcher/rank.mjs +5 -7
- package/scripts/lib/ecosystem-health.mjs +2 -2
- package/scripts/lib/eval/engine.mjs +3 -2
- package/scripts/lib/eval/session-resolve.mjs +6 -2
- package/scripts/lib/eval/sink.mjs +4 -3
- package/scripts/lib/events-retention-banner.mjs +3 -2
- package/scripts/lib/events-schema.mjs +10 -0
- package/scripts/lib/events.mjs +23 -12
- package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
- package/scripts/lib/fleet-protocol.mjs +21 -0
- package/scripts/lib/gates/gate-full.mjs +172 -11
- package/scripts/lib/gates/gate-helpers.mjs +236 -23
- package/scripts/lib/git-config-drift.mjs +141 -7
- package/scripts/lib/github-export.mjs +447 -0
- package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
- package/scripts/lib/handover-queue-marks.mjs +159 -0
- package/scripts/lib/hardware-pattern-detector.mjs +103 -13
- package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
- package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
- package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
- package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
- package/scripts/lib/host-identity.mjs +9 -5
- package/scripts/lib/http-request.mjs +64 -0
- package/scripts/lib/instruction-budget-guard.mjs +30 -22
- package/scripts/lib/io.mjs +6 -3
- package/scripts/lib/issue-budget-reconcile.mjs +3 -2
- package/scripts/lib/issue-budget.mjs +36 -7
- package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
- package/scripts/lib/learnings/io.mjs +289 -15
- package/scripts/lib/learnings/select.mjs +53 -4
- package/scripts/lib/learnings/store-role.mjs +209 -0
- package/scripts/lib/learnings/surface.mjs +46 -7
- package/scripts/lib/ledger-path.mjs +113 -0
- package/scripts/lib/maintenance-due-banner.mjs +63 -12
- package/scripts/lib/memory-banner.mjs +5 -6
- package/scripts/lib/memory-proposals/collector.mjs +5 -2
- package/scripts/lib/memory-proposals/sink.mjs +7 -4
- package/scripts/lib/memory-proposals/store.mjs +22 -5
- package/scripts/lib/mode-selector-accuracy.mjs +2 -1
- package/scripts/lib/no-follow-open.mjs +57 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
- package/scripts/lib/own-lock-root.mjs +95 -0
- package/scripts/lib/owner-interview.mjs +2 -1
- package/scripts/lib/owner-yaml.mjs +70 -7
- package/scripts/lib/platform.mjs +161 -9
- package/scripts/lib/plugin-update-banner.mjs +15 -11
- package/scripts/lib/pre-push-local-partial.mjs +117 -0
- package/scripts/lib/pre-push-remote-gate.mjs +151 -25
- package/scripts/lib/process-group.mjs +270 -34
- package/scripts/lib/project-hygiene.mjs +28 -0
- package/scripts/lib/quality-gate.mjs +33 -9
- package/scripts/lib/reconcile/backlog.mjs +29 -15
- package/scripts/lib/reconcile/engine.mjs +26 -2
- package/scripts/lib/reconcile/idempotency.mjs +65 -3
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
- package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
- package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
- package/scripts/lib/resource-probe/evaluate.mjs +32 -9
- package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
- package/scripts/lib/rule-loader.mjs +20 -10
- package/scripts/lib/scope-baseline.mjs +123 -8
- package/scripts/lib/scope-echo.mjs +5 -2
- package/scripts/lib/scope-gate.mjs +55 -20
- package/scripts/lib/session-close-backfill.mjs +75 -7
- package/scripts/lib/session-end/close-precheck.mjs +151 -0
- package/scripts/lib/session-end/phase-skip.mjs +8 -7
- package/scripts/lib/session-end/tail-runner.mjs +7 -2
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +85 -12
- package/scripts/lib/session-schema/constants.mjs +9 -0
- package/scripts/lib/session-schema/filters.mjs +56 -1
- package/scripts/lib/session-schema/validator.mjs +15 -5
- package/scripts/lib/session-start-probes.mjs +149 -22
- package/scripts/lib/session-token-rollup.mjs +2 -1
- package/scripts/lib/session-work-trees.mjs +166 -0
- package/scripts/lib/sessions-canonical.mjs +61 -3
- package/scripts/lib/sessions-integrity-banner.mjs +17 -39
- package/scripts/lib/sessions-staleness-banner.mjs +8 -5
- package/scripts/lib/sh-quote.mjs +1 -0
- package/scripts/lib/skill-evidence-window.mjs +5 -1
- package/scripts/lib/skill-health/join.mjs +10 -9
- package/scripts/lib/skill-invocations-schema.mjs +13 -0
- package/scripts/lib/skill-judgments-schema.mjs +6 -2
- package/scripts/lib/snapshot-probe.mjs +414 -0
- package/scripts/lib/so-hud/collect.mjs +174 -0
- package/scripts/lib/so-hud/runtime.mjs +87 -0
- package/scripts/lib/spiral-carryover.mjs +8 -1
- package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
- package/scripts/lib/state-md/idle-reset.mjs +248 -0
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/state-md-peer-guard.mjs +2 -0
- package/scripts/lib/state-md.mjs +31 -8
- package/scripts/lib/sunset/walker.mjs +4 -3
- package/scripts/lib/telemetry/consent.mjs +4 -2
- package/scripts/lib/telemetry/queue.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +45 -9
- package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
- package/scripts/lib/tmux-layout/layouts.mjs +20 -3
- package/scripts/lib/ux-grill/paths.mjs +5 -1
- package/scripts/lib/validate/check-agents.mjs +37 -6
- package/scripts/lib/validate/check-component-paths.mjs +25 -1
- package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
- package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
- package/scripts/lib/validate/check-json-files.mjs +3 -1
- package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
- package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
- package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
- package/scripts/lib/validate/check-unwired-features.mjs +4 -2
- package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
- package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
- package/scripts/lib/validate/confidential-names.mjs +3 -1
- package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
- package/scripts/lib/vault-mirror/namespace.mjs +22 -9
- package/scripts/lib/vault-mirror/process.mjs +197 -65
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
- package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
- package/scripts/lib/vault-mirror/retention.mjs +2 -2
- package/scripts/lib/vault-mirror/utils.mjs +106 -13
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +34 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
- package/scripts/lib/win-process-tree.mjs +137 -0
- package/scripts/lib/workspace.mjs +1 -1
- package/scripts/lib/worktree/constants.mjs +1 -1
- package/scripts/lib/worktree/lifecycle.mjs +1 -1
- package/scripts/lib/worktree/listing.mjs +1 -1
- package/scripts/lib/zx.mjs +50 -0
- package/scripts/mcp-server.mjs +16 -7
- package/scripts/mcp-server.sh +13 -83
- package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
- package/scripts/memory-propose.mjs +10 -3
- package/scripts/migrate-cold-start-seed.mjs +7 -0
- package/scripts/migrate-legacy-learnings.sh +22 -0
- package/scripts/migrate-sessions-jsonl.mjs +16 -2
- package/scripts/migrate-vault-paths.mjs +29 -13
- package/scripts/parse-config.mjs +71 -14
- package/scripts/print-learnings-index.mjs +25 -2
- package/scripts/release-session-lock.mjs +94 -7
- package/scripts/release.mjs +451 -24
- package/scripts/relocate-vault-corpus.mjs +11 -7
- package/scripts/repair-invalid-sessions.mjs +68 -3
- package/scripts/resolve-session-invocation.mjs +57 -8
- package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
- package/scripts/run-quality-gate.mjs +33 -7
- package/scripts/self-update.mjs +56 -7
- package/scripts/session-commit-files.mjs +133 -0
- package/scripts/site-numbers.mjs +52 -9
- package/scripts/snapshot-probe.mjs +156 -0
- package/scripts/so-hud.mjs +68 -0
- package/scripts/state-md-idle-reset.mjs +82 -0
- package/scripts/sweep-expired-learnings.mjs +54 -6
- package/scripts/sweep-expired-rules.mjs +4 -1
- package/scripts/validate-plugin.mjs +22 -0
- package/scripts/vault-integration-watcher.mjs +54 -6
- package/scripts/vault-mirror.mjs +9 -0
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +166 -3
- package/skills/_shared/instruction-file-resolution.md +1 -1
- package/skills/_shared/monitor-patterns.md +44 -25
- package/skills/_shared/platform-tools.md +7 -0
- package/skills/_shared/state-ownership.md +5 -4
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/autopilot/SKILL.md +3 -2
- package/skills/bootstrap/SKILL.md +3 -1
- package/skills/bootstrap/_shared-template.md +4 -4
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +1 -1
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
- package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
- package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
- package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
- package/skills/claude-md-drift-check/SKILL.md +3 -3
- package/skills/claude-md-drift-check/checker.mjs +61 -101
- package/skills/close/SKILL.md +20 -7
- package/skills/discovery/probes/docs-staleness.mjs +7 -5
- package/skills/discovery/probes/frontend-slop.mjs +7 -3
- package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
- package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
- package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
- package/skills/discovery/probes/vault-staleness.mjs +5 -3
- package/skills/discovery/probes-session.md +3 -0
- package/skills/discovery/probes-vault.md +2 -0
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eval/SKILL.md +3 -1
- package/skills/evolve/SKILL.md +2 -0
- package/skills/evolve/references/evolve-analyze-mode.md +3 -3
- package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
- package/skills/gitlab-ops/SKILL.md +7 -6
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/go/SKILL.md +13 -1
- package/skills/npm-publish/SKILL.md +5 -2
- package/skills/plan/SKILL.md +23 -10
- package/skills/plan/mode-feature.md +13 -8
- package/skills/plan/mode-new.md +6 -14
- package/skills/plan/prd-feature-template.md +19 -0
- package/skills/plan/prd-full-template.md +12 -5
- package/skills/plan/prd-premortem-panel.md +48 -0
- package/skills/plan/prd-reviewer-prompt.md +11 -3
- package/skills/plan/soul.md +1 -1
- package/skills/portfolio/SKILL.md +5 -6
- package/skills/quality-gates/SKILL.md +2 -4
- package/skills/reconcile/SKILL.md +27 -4
- package/skills/release/SKILL.md +16 -8
- package/skills/session-end/SKILL.md +97 -13
- package/skills/session-end/phase-3-2-docs-verification.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +11 -7
- package/skills/session-end/phase-3-7a-recommendations.md +9 -2
- package/skills/session-end/plan-verification.md +16 -1
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +8 -0
- package/skills/session-end/session-metrics-write.md +7 -4
- package/skills/session-plan/SKILL.md +5 -3
- package/skills/session-start/SKILL.md +10 -4
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/phase-8-5-express-path.md +4 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
- package/skills/session-start/references/phase-1-5-session-continuity.md +13 -5
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
- package/skills/tmux-layout/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +3 -2
- package/skills/wave-executor/SKILL.md +5 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
- package/skills/wave-executor/references/wave-loop-review.md +13 -5
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
- package/AGENTS.md +0 -207
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -0,0 +1,2998 @@
|
|
|
1
|
+
// The pure half of the so-hud protection gates (#1551): reading a shell command, a tool's output
|
|
2
|
+
// and a peer's scope. Nothing here touches `$`, the engine or node:, so every rule runs inside the
|
|
3
|
+
// mod engine and is testable on its own. Ported from an internal mod; only its general rules are
|
|
4
|
+
// carried over.
|
|
5
|
+
|
|
6
|
+
import { scanHeredocs, type HeredocStage } from './heredoc-scan'
|
|
7
|
+
|
|
8
|
+
// ---- Reading a shell command ------------------------------------------------
|
|
9
|
+
|
|
10
|
+
// One part of a shell word: a double-quoted run, a single-quoted run, a backslash escape, plain text,
|
|
11
|
+
// or a quote that never closes (kept as written).
|
|
12
|
+
const WORD_PART = /"((?:[^"\\]|\\.)*)"|'([^']*)'|\\(.)|([^"'\\]+)|(["'\\])/gs
|
|
13
|
+
|
|
14
|
+
/** A shell word with its quotes and escapes removed, as the shell passes it on: `"HEAD:ma"in` is `HEAD:main`. */
|
|
15
|
+
function dequote(word: string): string {
|
|
16
|
+
return word.replace(WORD_PART, (_m: string, dq?: string, sq?: string, esc?: string, plain?: string, lone?: string) =>
|
|
17
|
+
dq !== undefined ? dq.replace(/\\(["\\$`])/g, '$1') : (sq ?? esc ?? plain ?? lone ?? ''))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Splits one segment into shell words: each word is a run of `"…"`, `'…'` and unquoted characters
|
|
22
|
+
* joined together, quotes and escapes removed. Good enough to read flags and paths.
|
|
23
|
+
*/
|
|
24
|
+
export function tokenize(text: string): string[] {
|
|
25
|
+
return rawWords(text).map(dequote)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The shell words of a segment as written, quotes and escapes kept. */
|
|
29
|
+
function rawWords(text: string): string[] {
|
|
30
|
+
return [...text.matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|\\.|[^\s"'\\]+|["'\\])+/gs)].map(m => m[0])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// An output redirection or descriptor copy as written: `2>&1`, `>/dev/null`, `&>>log`, `<&3`, or an
|
|
34
|
+
// operator alone (`>`, `2>`, `&>`) whose target is the next word. Input redirections (`< .env`) stay
|
|
35
|
+
// words: the file they name is read by the command.
|
|
36
|
+
const REDIRECT = /^(?:(?:\d+|&)?>[>|&]?|\d*<&)(.*)$/s
|
|
37
|
+
// An input redirection as written: `<file`, `0< in`, `<>file`, a here-string `<<<word`, a heredoc's
|
|
38
|
+
// `<<EOF` / `<<-EOF`. Only the outward reader drops them (`</dev/null git push` is `git push`); a
|
|
39
|
+
// trap still reads the file a command is fed (`cat < .env`).
|
|
40
|
+
const IN_REDIRECT = /^\d*(?:<<<|<<-?|<>|<)(.*)$/s
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The raw words without their output redirections — and, when `input`, their input redirections:
|
|
44
|
+
* the shell removes them before the command sees its words, so `git 2>/dev/null push` is `git push`.
|
|
45
|
+
* Checked before quote removal, so a quoted `">"` stays an argument; `>(…)` and `<(…)` (process
|
|
46
|
+
* substitution) stay words too.
|
|
47
|
+
*/
|
|
48
|
+
function dropRedirections(raw: readonly string[], input: boolean): string[] {
|
|
49
|
+
const out: string[] = []
|
|
50
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
51
|
+
const word = raw[i] ?? ''
|
|
52
|
+
const target = REDIRECT.exec(word)?.[1] ?? (input ? IN_REDIRECT.exec(word)?.[1] : undefined)
|
|
53
|
+
if (target === undefined || target.startsWith('(')) out.push(word)
|
|
54
|
+
else if (target === '') i += 1
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Where a redirection or process substitution starts after its `<` or `>`, longest form first.
|
|
60
|
+
const REDIRECT_OP = /<<<|<<-?|<>|<&|<\(|<|>\(|>>|>&|>\||>/y
|
|
61
|
+
// The characters that end a redirection target unquoted in every shell; also where the next one may
|
|
62
|
+
// start. An unquoted `&` inside a target starts `&>` (a lone one already ended the segment): `x&>y`.
|
|
63
|
+
const TARGET_END = /[<>()&]/g
|
|
64
|
+
const ANGLE = /[<>]/g
|
|
65
|
+
|
|
66
|
+
/** The word with every quoted run and backslash escape blanked to `_`, same length: what is left is unquoted. */
|
|
67
|
+
function maskQuoted(word: string): string {
|
|
68
|
+
return word.replace(WORD_PART, (m: string, _dq?: string, _sq?: string, _esc?: string, plain?: string) => (plain === undefined ? '_'.repeat(m.length) : m))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether an output redirection writes nothing: `>`, `>>` or `&>` (any descriptor) onto exactly
|
|
73
|
+
* `/dev/null`, or `>&` copying stdout or stderr onto the other. Every other form, target or descriptor
|
|
74
|
+
* (`>&3`, `3>&1`, `&>>`, `<>`, `<&`, `>|`, a quoted or `$` target) counts as a write.
|
|
75
|
+
*/
|
|
76
|
+
function harmlessOutput(op: string, fd: string, target: string): boolean {
|
|
77
|
+
if (op === '>' || op === '>>' || op === '&>') return target === '/dev/null'
|
|
78
|
+
return op === '>&' && (fd === '' || fd === '1' || fd === '2') && (target === '1' || target === '2')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether a segment carries an output redirection that may write a file (#1569, MR !133): `git log
|
|
83
|
+
* --format=… >> .git/config` wrote the push configuration the facts never saw. Read quote-aware over the
|
|
84
|
+
* same words as {@link readCommand}, also where the reader keeps the word (`--short>>.git/config`), so a
|
|
85
|
+
* quoted `"a > b"` is no redirection. Input redirections (`<`, `N<`, `<<`, `<<<`) and process
|
|
86
|
+
* substitutions (`<(…)`, `>(…)`, read by {@link gitInSubstitution}) write nothing here. One pass per word.
|
|
87
|
+
*/
|
|
88
|
+
function writesOutput(segment: string): boolean {
|
|
89
|
+
// An operator whose target is the next word: `> .git/HEAD`. `in` skips that word, `out` judges it.
|
|
90
|
+
let pending: { kind: 'in' | 'out'; op: string; fd: string } | null = null
|
|
91
|
+
for (const word of rawWords(stripGroup(segment))) {
|
|
92
|
+
const masked = maskQuoted(word)
|
|
93
|
+
let at = 0
|
|
94
|
+
// The target runs to the next unquoted `<`, `>`, `(`, `)` or `&`: `/dev/null)` closes a `$(…)`.
|
|
95
|
+
const targetEnd = (from: number): number => {
|
|
96
|
+
TARGET_END.lastIndex = from
|
|
97
|
+
return TARGET_END.exec(masked)?.index ?? word.length
|
|
98
|
+
}
|
|
99
|
+
if (pending !== null) {
|
|
100
|
+
at = targetEnd(0)
|
|
101
|
+
if (pending.kind === 'out' && !harmlessOutput(pending.op, pending.fd, word.slice(0, at))) return true
|
|
102
|
+
pending = null
|
|
103
|
+
}
|
|
104
|
+
for (;;) {
|
|
105
|
+
ANGLE.lastIndex = at
|
|
106
|
+
const angle = ANGLE.exec(masked)?.index
|
|
107
|
+
if (angle === undefined) break
|
|
108
|
+
REDIRECT_OP.lastIndex = angle
|
|
109
|
+
let op = REDIRECT_OP.exec(masked)?.[0] ?? ''
|
|
110
|
+
let opEnd = angle + op.length
|
|
111
|
+
if (op === '<(' || op === '>(') {
|
|
112
|
+
at = opEnd
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
// `&>` / `&>>`: the `&` belongs to the operator; otherwise the digits in front are its descriptor.
|
|
116
|
+
let fd = ''
|
|
117
|
+
if (op.startsWith('>') && masked[angle - 1] === '&') op = `&${op}`
|
|
118
|
+
else {
|
|
119
|
+
let start = angle
|
|
120
|
+
while (start > at && /\d/.test(masked[start - 1] ?? '')) start -= 1
|
|
121
|
+
fd = masked.slice(start, angle)
|
|
122
|
+
}
|
|
123
|
+
if (op === '&>' && masked[opEnd] === '>') {
|
|
124
|
+
op = '&>>'
|
|
125
|
+
opEnd += 1
|
|
126
|
+
}
|
|
127
|
+
const kind = op.startsWith('<') && op !== '<>' && op !== '<&' ? 'in' : 'out'
|
|
128
|
+
if (opEnd >= word.length) {
|
|
129
|
+
pending = { kind, op, fd }
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
at = targetEnd(opEnd)
|
|
133
|
+
if (kind === 'out' && !harmlessOutput(op, fd, word.slice(opEnd, at))) return true
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// An output operator with no target left (`git log >`, cut at a `|`): fail-closed.
|
|
137
|
+
return pending?.kind === 'out'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The words of a segment as the command receives them: redirections dropped (input ones when `input`), quotes removed. */
|
|
141
|
+
function argWords(text: string, input = false): string[] {
|
|
142
|
+
return dropRedirections(rawWords(text), input).map(dequote)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Words that can come before the real command without changing what it does. `eval` with unquoted
|
|
146
|
+
// words runs exactly those words; its quoted form is read by {@link extractInner}. `if`, `while`,
|
|
147
|
+
// `until` and `elif` run their condition as a command, `then`, `do` and `else` their body.
|
|
148
|
+
const PREFIXES = new Set(['command', 'exec', 'nohup', 'time', 'if', 'elif', 'while', 'until', 'then', 'do', 'else', '!', 'eval'])
|
|
149
|
+
|
|
150
|
+
// The options of the transparent prefixes and runners that take a separate value.
|
|
151
|
+
const ENV_VALUED = new Set(['-u', '--unset'])
|
|
152
|
+
const SUDO_VALUED = new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '--user', '--group', '--host', '--prompt', '--close-from', '--chdir', '--role', '--type', '--other-user', '--command-timeout'])
|
|
153
|
+
const NICE_VALUED = new Set(['-n', '--adjustment'])
|
|
154
|
+
const TIMEOUT_VALUED = new Set(['-s', '--signal', '-k', '--kill-after'])
|
|
155
|
+
const RUNNER_VALUED = new Set(['-p', '--package'])
|
|
156
|
+
// caffeinate's -t <seconds> and -w <pid>, also as the last letter of a group (`-it 5`).
|
|
157
|
+
const CAFFEINATE_VALUED = /^-[a-z]*[tw]$/
|
|
158
|
+
|
|
159
|
+
// A variable assignment word: `NAME=value`, `NAME+=value`, and an array element `NAME[i]=value` (zsh: `HOME[1,-1]=x`
|
|
160
|
+
// rewrites HOME, #1571); group 1 is the name.
|
|
161
|
+
const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?\+?=/
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A segment without the `(`/`{` in front and the `)`/`}` behind it. Trimmed by hand: `\s*[)}]+$`
|
|
165
|
+
* retried from every blank of a long run, quadratic on attacker-controlled length.
|
|
166
|
+
*/
|
|
167
|
+
function stripGroup(segment: string): string {
|
|
168
|
+
const text = segment.trim().replace(/^[({]+\s*/, '')
|
|
169
|
+
let end = text.length
|
|
170
|
+
while (end > 0 && (text[end - 1] === ')' || text[end - 1] === '}')) end -= 1
|
|
171
|
+
return end === text.length ? text : text.slice(0, end).trimEnd()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The command a segment runs, read with an index (a `shift()` per word was quadratic: 535 ms for 50k
|
|
176
|
+
* characters of `npx -y`): its words with redirections, leading `VAR=x`, `env -u X`, `sudo`, `nice`,
|
|
177
|
+
* `timeout`, `caffeinate`, the prefixes above and a runner's own options removed, and the names those
|
|
178
|
+
* leading assignments set. `input` also drops input redirections (the outward reader).
|
|
179
|
+
*/
|
|
180
|
+
function readCommand(segment: string, input: boolean): { words: string[]; assigned: string[] } {
|
|
181
|
+
const words = argWords(stripGroup(segment), input)
|
|
182
|
+
const assigned: string[] = []
|
|
183
|
+
let i = 0
|
|
184
|
+
// Skips options (and, for those `valued` names, their value) up to the first word that is none, or past `--`.
|
|
185
|
+
// `env -u NAME` / `--unset=NAME` removes NAME for the command: recorded like an assignment (`env -u HOME git push`).
|
|
186
|
+
const skipOptions = (valued: ReadonlySet<string> | RegExp, unsets = false): void => {
|
|
187
|
+
while ((words[i] ?? '').startsWith('-')) {
|
|
188
|
+
const option = words[i] ?? ''
|
|
189
|
+
i += 1
|
|
190
|
+
if (option === '--') break
|
|
191
|
+
if (unsets && option.startsWith('--unset=')) assigned.push(option.slice('--unset='.length))
|
|
192
|
+
else if (unsets && /^-u./.test(option)) assigned.push(option.slice(2))
|
|
193
|
+
if (valued instanceof RegExp ? valued.test(option) : valued.has(option)) {
|
|
194
|
+
if (unsets) assigned.push(words[i] ?? '')
|
|
195
|
+
i += 1
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (;;) {
|
|
200
|
+
const word = words[i]
|
|
201
|
+
if (word === undefined) break
|
|
202
|
+
const assignment = ASSIGNMENT.exec(word)
|
|
203
|
+
if (assignment !== null || PREFIXES.has(word)) {
|
|
204
|
+
if (assignment !== null) assigned.push(assignment[1] ?? '')
|
|
205
|
+
i += 1
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (word === 'env' || word === 'sudo' || word === 'nice') {
|
|
209
|
+
i += 1
|
|
210
|
+
skipOptions(word === 'env' ? ENV_VALUED : word === 'sudo' ? SUDO_VALUED : NICE_VALUED, word === 'env')
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (word === 'timeout' || word === 'gtimeout') {
|
|
214
|
+
i += 1
|
|
215
|
+
skipOptions(TIMEOUT_VALUED)
|
|
216
|
+
i += 1 // the duration
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (word === 'caffeinate') {
|
|
220
|
+
i += 1
|
|
221
|
+
skipOptions(CAFFEINATE_VALUED)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
// npx vercel, npx --yes npm publish, pnpm dlx vercel, npm exec -- changeset: the tool is the next
|
|
227
|
+
// word after the runner's own options.
|
|
228
|
+
if (words[i] === 'npx' || words[i] === 'bunx') {
|
|
229
|
+
i += 1
|
|
230
|
+
skipOptions(RUNNER_VALUED)
|
|
231
|
+
} else if ((words[i] === 'pnpm' && (words[i + 1] === 'dlx' || words[i + 1] === 'exec')) || (words[i] === 'npm' && (words[i + 1] === 'exec' || words[i + 1] === 'x'))) {
|
|
232
|
+
i += 2
|
|
233
|
+
skipOptions(RUNNER_VALUED)
|
|
234
|
+
}
|
|
235
|
+
return { words: words.slice(i), assigned }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The words of one segment, redirections, leading `VAR=x`, `env -u X`, `sudo`, `nice`, `timeout`, `caffeinate` and the prefixes above removed. */
|
|
239
|
+
export function commandWords(segment: string): string[] {
|
|
240
|
+
return readCommand(segment, false).words
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `\` + newline removed, as the shell does before it reads words: `git \<LF>push` is `git push`. */
|
|
244
|
+
function joinContinuations(command: string): string {
|
|
245
|
+
return command.replace(/\\\r?\n/g, '')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- What the shell never runs: comments and data heredocs (#1572, #1574) -----
|
|
249
|
+
|
|
250
|
+
// The characters after which an unquoted `#` starts a comment (the start of a word).
|
|
251
|
+
const WORD_BREAK = /[\s;&|()<>]/
|
|
252
|
+
// A heredoc operator and its delimiter: `<<EOF`, `<<-EOF`, `<< 'EOF'`, `<<"EOF"`, `<<\EOF`. Group 1 is `-`, groups
|
|
253
|
+
// 2 and 3 a single- or double-quoted delimiter, group 4 a backslash-quoted one, group 5 an unquoted one.
|
|
254
|
+
const HEREDOC_OP = /<<(-?)[ \t]*(?:(?:'([A-Za-z_][\w.-]*)'|"([A-Za-z_][\w.-]*)")|\\([A-Za-z_][\w.-]*)|([A-Za-z_][\w.-]*))/y
|
|
255
|
+
// `$(cat <<'EOF'` up to the end of its line: a substitution whose only command is `cat` reading a heredoc with a quoted delimiter.
|
|
256
|
+
const CAT_HEREDOC = /\$\([ \t]*cat[ \t]+<<(-?)[ \t]*(?:'([A-Za-z_][\w.-]*)'|"([A-Za-z_][\w.-]*)")[ \t]*\n/y
|
|
257
|
+
// What may follow a heredoc operator on its own line for the body to stay data: words, `>`, `&&`, `||`, `;`, `&` and
|
|
258
|
+
// simple quotes — no `|` into another command, no second heredoc, no `$`, backtick or backslash.
|
|
259
|
+
const HEREDOC_REST = /^(?:[^'"`$\\|<\n]|'[^'\n]*'|"[^"\\$`\n]*"|\|\|)*$/
|
|
260
|
+
// A `${name}` that nests nothing; any other `${` stops the view like `$(`.
|
|
261
|
+
const PLAIN_BRACE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/y
|
|
262
|
+
// Heredocs whose closing line was not found before the view gives up (each miss reads to the end of the line).
|
|
263
|
+
const MAX_HEREDOC_MISSES = 4
|
|
264
|
+
// An `eval` word: what it is given is parsed again as shell code, so nothing quoted behind it is text.
|
|
265
|
+
const EVAL_WORD = /(?:^|[\s;&|(){}])eval(?=\s)/
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Whether the command a heredoc or `$(cat <<'EOF' …)` feeds treats it as text: `cat` printing it (no file
|
|
269
|
+
* written), `git commit` (its message), or a forge's issue/merge-request/pull-request command (a body). Never a
|
|
270
|
+
* shell, an interpreter, `gh api` (a GraphQL body) or anything else (#1574: `bash <<'EOF'` still runs its body).
|
|
271
|
+
*/
|
|
272
|
+
function readsAsText(words: readonly string[], writes: boolean): boolean {
|
|
273
|
+
const [first, ...args] = words
|
|
274
|
+
const cmd = baseName(first ?? '')
|
|
275
|
+
if (cmd === 'cat') return !writes && args.every(arg => arg === '-')
|
|
276
|
+
if (cmd === 'git') {
|
|
277
|
+
const git = readGit(args, null)
|
|
278
|
+
return git.sub === 'commit' && git.config.length === 0
|
|
279
|
+
}
|
|
280
|
+
return (cmd === 'gh' || cmd === 'glab') && (args[0] === 'issue' || args[0] === 'mr' || args[0] === 'pr')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether the body of a heredoc with an unquoted delimiter runs nothing: no backtick, `$(`, `$((`, `$[` or `${…}` other
|
|
285
|
+
* than `${name}` — a plain `$name` only expands. (A quoted delimiter keeps every body literal.)
|
|
286
|
+
*/
|
|
287
|
+
function inertBody(body: string): boolean {
|
|
288
|
+
return !/`|\$[([]|\$\{(?![A-Za-z_][A-Za-z0-9_]*\})/.test(body)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The index just past the closing line of a heredoc whose body starts at `from`, or -1 (`tabs`: `<<-`, leading tabs stripped). */
|
|
292
|
+
function heredocEnd(line: string, from: number, delimiter: string, tabs: boolean): number {
|
|
293
|
+
let at = from
|
|
294
|
+
while (at <= line.length) {
|
|
295
|
+
const nl = line.indexOf('\n', at)
|
|
296
|
+
const end = nl === -1 ? line.length : nl
|
|
297
|
+
const text = line.slice(at, end)
|
|
298
|
+
if ((tabs ? text.replace(/^\t+/, '') : text) === delimiter) return end
|
|
299
|
+
if (nl === -1) return -1
|
|
300
|
+
at = nl + 1
|
|
301
|
+
}
|
|
302
|
+
return -1
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The line as the shell runs it, for every reader below: each comment removed, and each heredoc that is only text
|
|
307
|
+
* replaced by the word `<_` (an input redirection the readers drop), its body and closing line removed — so a commit
|
|
308
|
+
* message, an issue body or a comment that mentions `git push` is no command, and `git commit -m "$(cat <<'EOF' …
|
|
309
|
+
* EOF)"` (Claude Code's own recipe) is one git step, not a `cat` before the push (#1574). A heredoc is text only with
|
|
310
|
+
* a quoted delimiter (or an unquoted one with an {@link inertBody}) and a reader per
|
|
311
|
+
* {@link readsAsText}; `$(cat <<'EOF' … EOF)` only inside double quotes, its closing `)` right after the closing line.
|
|
312
|
+
* Read with the quote tracking of {@link scanLine}: from the first newline inside a quote or construct that nests its
|
|
313
|
+
* own quoting (`$(`, a backtick, `${…}` other than `${name}`, `$'`, `<(`, `>(`, a heredoc that is not text) the rest
|
|
314
|
+
* is kept as written — over-reading only asks. A `#` starts a comment at the start of a word outside quotes: a push
|
|
315
|
+
* between `# bash -c '` and `# '` runs (#1572 E1).
|
|
316
|
+
*/
|
|
317
|
+
function dataView(line: string): string {
|
|
318
|
+
const out: string[] = []
|
|
319
|
+
let quote: '"' | "'" | null = null
|
|
320
|
+
let segStart = 0
|
|
321
|
+
let misses = 0
|
|
322
|
+
let copied = 0
|
|
323
|
+
let consumerAt = -1
|
|
324
|
+
let consumerText = false
|
|
325
|
+
const flush = (to: number): void => {
|
|
326
|
+
if (to > copied) out.push(line.slice(copied, to))
|
|
327
|
+
copied = to
|
|
328
|
+
}
|
|
329
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
330
|
+
const ch = line[i] ?? ''
|
|
331
|
+
if (quote === "'") {
|
|
332
|
+
if (ch === "'") quote = null
|
|
333
|
+
else if (ch === '\n') break
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
if (ch === '\\') {
|
|
337
|
+
i += 1
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
if (ch === '\n' && quote !== null) break
|
|
341
|
+
if (ch === '`') break
|
|
342
|
+
if (ch === '$') {
|
|
343
|
+
const next = line[i + 1]
|
|
344
|
+
if (next === '{') {
|
|
345
|
+
PLAIN_BRACE.lastIndex = i
|
|
346
|
+
if (!PLAIN_BRACE.test(line)) break
|
|
347
|
+
i = PLAIN_BRACE.lastIndex - 1
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
if (next === "'") break
|
|
351
|
+
if (next !== '(') continue
|
|
352
|
+
CAT_HEREDOC.lastIndex = i
|
|
353
|
+
const cat = CAT_HEREDOC.exec(line)
|
|
354
|
+
const delimiter = cat?.[2] ?? cat?.[3]
|
|
355
|
+
if (cat === null || delimiter === undefined || quote !== '"' || misses >= MAX_HEREDOC_MISSES) break
|
|
356
|
+
const end = heredocEnd(line, CAT_HEREDOC.lastIndex, delimiter, cat[1] === '-')
|
|
357
|
+
const close = end === -1 ? null : /^\s*\)/.exec(line.slice(end, end + 64))
|
|
358
|
+
if (close === null) {
|
|
359
|
+
misses += 1
|
|
360
|
+
break
|
|
361
|
+
}
|
|
362
|
+
// Judged once per segment: many substitutions in one quoted word would re-read its growing prefix (quadratic).
|
|
363
|
+
if (consumerAt !== segStart) {
|
|
364
|
+
const prefix = line.slice(segStart, i)
|
|
365
|
+
const consumer = readCommand(prefix, true).words
|
|
366
|
+
consumerAt = segStart
|
|
367
|
+
// `eval git commit -m "$(cat …)"` parses the message again as shell code.
|
|
368
|
+
consumerText = readsAsText(consumer, false) && baseName(consumer[0] ?? '') !== 'cat' && !EVAL_WORD.test(prefix)
|
|
369
|
+
}
|
|
370
|
+
if (!consumerText) break
|
|
371
|
+
flush(i)
|
|
372
|
+
out.push('_')
|
|
373
|
+
copied = end + close[0].length
|
|
374
|
+
i = copied - 1
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if (quote === '"') {
|
|
378
|
+
if (ch === '"') quote = null
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
if (ch === '"' || ch === "'") quote = ch
|
|
382
|
+
else if ((ch === '<' || ch === '>') && line[i + 1] === '(') break
|
|
383
|
+
else if (ch === '#' && (i === 0 || WORD_BREAK.test(line[i - 1] ?? ''))) {
|
|
384
|
+
flush(i)
|
|
385
|
+
const nl = line.indexOf('\n', i)
|
|
386
|
+
copied = nl === -1 ? line.length : nl
|
|
387
|
+
i = copied - 1
|
|
388
|
+
} else if (ch === '<' && line[i + 1] === '<' && line[i + 2] === '<') i += 2
|
|
389
|
+
else if (ch === '<' && line[i + 1] === '<') {
|
|
390
|
+
HEREDOC_OP.lastIndex = i
|
|
391
|
+
const op = HEREDOC_OP.exec(line)
|
|
392
|
+
const delimiter = op?.[2] ?? op?.[3] ?? op?.[4] ?? op?.[5]
|
|
393
|
+
if (op === null || delimiter === undefined || misses >= MAX_HEREDOC_MISSES) break
|
|
394
|
+
const opEnd = HEREDOC_OP.lastIndex
|
|
395
|
+
const nl = line.indexOf('\n', opEnd)
|
|
396
|
+
const rest = line.slice(opEnd, nl === -1 ? line.length : nl)
|
|
397
|
+
if (nl === -1 || !HEREDOC_REST.test(rest)) break
|
|
398
|
+
const end = heredocEnd(line, nl + 1, delimiter, op[1] === '-')
|
|
399
|
+
if (end === -1) {
|
|
400
|
+
misses += 1
|
|
401
|
+
break
|
|
402
|
+
}
|
|
403
|
+
const reader = `${line.slice(segStart, i)} ${rest}`
|
|
404
|
+
const body = line.slice(nl + 1, end)
|
|
405
|
+
const literal = op[5] === undefined || inertBody(body)
|
|
406
|
+
if (!literal || !readsAsText(readCommand(reader, true).words, writesOutput(reader))) break
|
|
407
|
+
flush(i)
|
|
408
|
+
out.push(`<_${rest}`)
|
|
409
|
+
copied = end
|
|
410
|
+
i = end - 1
|
|
411
|
+
} else if (ch === ';' || ch === '&' || ch === '|' || ch === '\n' || ch === '(' || ch === ')') segStart = i + 1
|
|
412
|
+
}
|
|
413
|
+
flush(line.length)
|
|
414
|
+
return out.join('')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* One pass over a line. `marked` is the line with each background `&` turned into `;`, same length: a
|
|
419
|
+
* lone `&` ends a command like `;` does (`true & git push`). Not `&&`, `&>`, `>&`, `<&` or `|&`, and
|
|
420
|
+
* not after a backslash or inside quotes, where `&` is a character (`curl "…?a=1&b=2"`, a commit
|
|
421
|
+
* message) — but only while the quote tracking is certain. It stops being certain (`doubt`) at the
|
|
422
|
+
* first newline inside an open quote (a heredoc body or a multi-line string: which of the two, this
|
|
423
|
+
* cannot tell, and resetting the quote there flipped parity on the next line, so `"msg<NL>x=1"; git
|
|
424
|
+
* status & git switch main` hid the `&`) and at the first construct that nests its own quoting or is
|
|
425
|
+
* no shell word: `$(`, `${`, `$'`, a backtick, `<(`, `>(`, `<<`. From there every lone `&` is cut,
|
|
426
|
+
* quoted or not: over-reading finds more commands, never fewer.
|
|
427
|
+
* `unsure[i]` is 1 where position `i` may sit inside a quote — or after doubt began, or after an
|
|
428
|
+
* unquoted `(` or `{` (a group: a `cd` there may not reach what follows); `unsure[length]` is the state
|
|
429
|
+
* at the end.
|
|
430
|
+
*/
|
|
431
|
+
function scanLine(line: string): { marked: string; unsure: Uint8Array } {
|
|
432
|
+
const out: string[] = new Array<string>(line.length)
|
|
433
|
+
const unsure = new Uint8Array(line.length + 1)
|
|
434
|
+
let quote: string | null = null
|
|
435
|
+
let doubt = false
|
|
436
|
+
let group = false
|
|
437
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
438
|
+
const ch = line[i] ?? ''
|
|
439
|
+
const next = line[i + 1] ?? ''
|
|
440
|
+
if (!doubt && ((ch === '$' && (next === '(' || next === '{' || next === "'")) || ch === '`' || ((ch === '<' || ch === '>') && next === '(') || (ch === '<' && next === '<') || (ch === '\n' && quote !== null))) doubt = true
|
|
441
|
+
unsure[i] = doubt || group || quote !== null ? 1 : 0
|
|
442
|
+
out[i] = ch
|
|
443
|
+
if (ch === '&' && (quote === null || doubt) && !/[&<>|]/.test(line[i - 1] ?? '') && !/[&>]/.test(next)) out[i] = ';'
|
|
444
|
+
else if (quote === "'") {
|
|
445
|
+
if (ch === "'") quote = null
|
|
446
|
+
} else if (ch === '\\') {
|
|
447
|
+
i += 1
|
|
448
|
+
if (i < line.length) {
|
|
449
|
+
out[i] = line[i] ?? ''
|
|
450
|
+
unsure[i] = unsure[i - 1] ?? 1
|
|
451
|
+
}
|
|
452
|
+
} else if (quote === '"') {
|
|
453
|
+
if (ch === '"') quote = null
|
|
454
|
+
} else if (ch === '"' || ch === "'") quote = ch
|
|
455
|
+
else if (ch === '(' || ch === '{') group = true
|
|
456
|
+
}
|
|
457
|
+
unsure[line.length] = doubt || group || quote !== null ? 1 : 0
|
|
458
|
+
return { marked: out.join(''), unsure }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** The line with each background `&` turned into `;` ({@link scanLine}). */
|
|
462
|
+
function markBackground(line: string): string {
|
|
463
|
+
return scanLine(line).marked
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Where splitSegments cuts: `&&`, `||`, `|` and `|&`, `;`, newlines (a background `&` is a `;` by then).
|
|
467
|
+
const SEPARATOR = /&&|\|\||\|&?|;|\n/g
|
|
468
|
+
|
|
469
|
+
/** The command line cut at `&&`, `||`, `;`, `&`, `|`, `|&` and newlines. Quotes are honoured for `&` only: detection only. */
|
|
470
|
+
export function splitSegments(command: string): string[] {
|
|
471
|
+
return markBackground(joinContinuations(command)).split(SEPARATOR)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// A separator as written; '' is the start or end of the line.
|
|
475
|
+
type Sep = '' | '&&' | '||' | '|' | '|&' | ';' | '\n' | '&'
|
|
476
|
+
// The separators that end an and-or list (`a && b || c`): what follows runs whatever the list returned.
|
|
477
|
+
const LIST_END: ReadonlySet<Sep> = new Set<Sep>([';', '\n', '&'])
|
|
478
|
+
const PIPE: ReadonlySet<Sep> = new Set<Sep>(['|', '|&'])
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* A segment as {@link splitSegments} cuts it, with the separators around it. `sure`: at neither end can
|
|
482
|
+
* it sit inside a quote, after doubt began, or inside a group ({@link scanLine}) — so it is a command
|
|
483
|
+
* of its own in this shell, not a piece of a string a separator inside quotes cut out. `start`: its index in the line.
|
|
484
|
+
*/
|
|
485
|
+
type Seg = { text: string; before: Sep; after: Sep; sure: boolean; start: number }
|
|
486
|
+
|
|
487
|
+
function segmentsOf(command: string): Seg[] {
|
|
488
|
+
const line = joinContinuations(command)
|
|
489
|
+
const { marked, unsure } = scanLine(line)
|
|
490
|
+
const segs: Seg[] = []
|
|
491
|
+
let start = 0
|
|
492
|
+
let before: Sep = ''
|
|
493
|
+
const push = (end: number, after: Sep): void => {
|
|
494
|
+
segs.push({ text: line.slice(start, end), before, after, sure: unsure[start] === 0 && unsure[end] === 0, start })
|
|
495
|
+
}
|
|
496
|
+
for (const m of marked.matchAll(SEPARATOR)) {
|
|
497
|
+
const sep = (m[0] === ';' && line[m.index] === '&' ? '&' : m[0]) as Sep
|
|
498
|
+
push(m.index, sep)
|
|
499
|
+
start = m.index + m[0].length
|
|
500
|
+
before = sep
|
|
501
|
+
}
|
|
502
|
+
push(line.length, '')
|
|
503
|
+
return segs
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** The pipelines of a command line, each a list of its stages. */
|
|
507
|
+
export function splitPipelines(command: string): string[][] {
|
|
508
|
+
return markBackground(joinContinuations(command)).split(/&&|\|\||;|\n/).map(pipeline => pipeline.split(/\|&?/))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// A quoted command line run by another shell (`bash -c "…"`, `sh -lc '…'`), by a runner's shell mode
|
|
512
|
+
// (`npx -c '…'`, `pnpm exec -c '…'`) or by `eval "…"`. Its separators sit inside quotes, which
|
|
513
|
+
// splitSegments does not honour, so it is cut out first and read as a command line of its own.
|
|
514
|
+
// The shell runs one word after `-c` (the rest become $0, $1, …); `eval` joins all its words. A word
|
|
515
|
+
// that starts with `-` (`bash -c -- "…"`) or a shell this cannot cut is left whole: {@link unreadableOutward} reads it.
|
|
516
|
+
const SHELL_WORD = String.raw`(?:"(?:[^"\\]|\\.)*"|'[^']*'|\\.|[^\s"'\\;&|()<>]+)+`
|
|
517
|
+
const INNER_SOURCE = String.raw`(?<![\w./-])(?:(?:(?:bash|sh|zsh|dash|ksh)\s+(?:-[-\w]+\s+)*?-(?=[A-Za-z]*c)[A-Za-z]+|(?:npx|bunx|pnpm\s+(?:dlx|exec)|npm\s+(?:exec|x))\s+(?:-[-\w=]+\s+)*?(?:-c|--call|--shell-mode))\s+(?!-)(${SHELL_WORD})|eval((?:[ \t]+${SHELL_WORD})+))`
|
|
518
|
+
const INNER = new RegExp(INNER_SOURCE, 'g')
|
|
519
|
+
const INNER_AT = new RegExp(INNER_SOURCE, 'y')
|
|
520
|
+
const INNER_WORD = /^__SOHUD_INNER_(\d+)__$/
|
|
521
|
+
// Nesting deeper than this is read without cutting, so its words still split (and over-detect).
|
|
522
|
+
const MAX_DEPTH = 3
|
|
523
|
+
|
|
524
|
+
// `expanded`: the part of the inner line the OUTER shell expands before running it — all of an `eval`, and the
|
|
525
|
+
// double-quoted or bare parts of a `-c` word (a single-quoted `$x` is read by the child, which has no unexported x).
|
|
526
|
+
type Inner = { command: string; sameShell: boolean; expanded: string }
|
|
527
|
+
|
|
528
|
+
/** The line with each inner command line replaced by a placeholder word, and those command lines. */
|
|
529
|
+
function extractInner(line: string, depth: number): { text: string; inners: Inner[] } {
|
|
530
|
+
const inners: Inner[] = []
|
|
531
|
+
if (depth >= MAX_DEPTH) return { text: line, inners }
|
|
532
|
+
const text = line.replace(INNER, (_match: string, shellWord?: string, evalWords?: string) => {
|
|
533
|
+
if (shellWord !== undefined) {
|
|
534
|
+
const expanded = shellWord.replace(/"(?:[^"\\]|\\.)*"|'[^']*'|\\./g, part => (part.startsWith("'") ? '' : part))
|
|
535
|
+
inners.push({ command: dequote(shellWord), sameShell: false, expanded })
|
|
536
|
+
} else {
|
|
537
|
+
const command = tokenize(evalWords ?? '').join(' ')
|
|
538
|
+
inners.push({ command, sameShell: true, expanded: command })
|
|
539
|
+
}
|
|
540
|
+
return ` __SOHUD_INNER_${inners.length - 1}__ `
|
|
541
|
+
})
|
|
542
|
+
return { text, inners }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** The inner command line a segment runs, when its first word is a placeholder. */
|
|
546
|
+
function innerAt(word: string, inners: readonly Inner[]): Inner | null {
|
|
547
|
+
const m = INNER_WORD.exec(word)
|
|
548
|
+
return m ? (inners[Number(m[1])] ?? null) : null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function baseName(word: string): string {
|
|
552
|
+
return word.replace(/^\\/, '').split('/').pop() ?? word
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
|
|
556
|
+
function joinDir(dir: string | null, arg: string | undefined): string {
|
|
557
|
+
if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) {
|
|
558
|
+
return arg ?? '~'
|
|
559
|
+
}
|
|
560
|
+
return dir ? `${dir}/${arg}` : arg
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** The positional arguments, skipping each option and, for the options in `valued`, its value. */
|
|
564
|
+
export function positionals(args: readonly string[], valued: ReadonlySet<string>): string[] {
|
|
565
|
+
const out: string[] = []
|
|
566
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
567
|
+
const arg = args[i] ?? ''
|
|
568
|
+
if (arg === '--') {
|
|
569
|
+
out.push(...args.slice(i + 1))
|
|
570
|
+
break
|
|
571
|
+
}
|
|
572
|
+
if (arg.startsWith('-') && arg !== '-') {
|
|
573
|
+
if (valued.has(arg)) i += 1
|
|
574
|
+
continue
|
|
575
|
+
}
|
|
576
|
+
out.push(arg)
|
|
577
|
+
}
|
|
578
|
+
return out
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** The value of `--name value`, `--name=value` or `-n value`, or undefined. */
|
|
582
|
+
export function optionValue(args: readonly string[], ...names: string[]): string | undefined {
|
|
583
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
584
|
+
const arg = args[i] ?? ''
|
|
585
|
+
for (const name of names) {
|
|
586
|
+
if (arg === name) return args[i + 1]
|
|
587
|
+
if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return undefined
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---- Actions with an effect outside this machine -----------------------------
|
|
594
|
+
|
|
595
|
+
export type OutwardKind = 'push' | 'merge' | 'publish' | 'deploy' | 'release'
|
|
596
|
+
|
|
597
|
+
export const KIND_LABEL: Record<OutwardKind, string> = {
|
|
598
|
+
push: 'Push',
|
|
599
|
+
merge: 'Merge',
|
|
600
|
+
publish: 'Paket veröffentlichen',
|
|
601
|
+
deploy: 'Produktiv-Deploy',
|
|
602
|
+
release: 'Release',
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export type Outward = {
|
|
606
|
+
kind: OutwardKind
|
|
607
|
+
/** The detected action. */
|
|
608
|
+
label: string
|
|
609
|
+
/** The command's own words after the subcommand. */
|
|
610
|
+
args: string[]
|
|
611
|
+
/**
|
|
612
|
+
* Where a `cd` earlier on the line moved to; null means the session folder. A `cd`, `pushd` or
|
|
613
|
+
* `popd` whose effect on this command is not certain — inside `( )`, `{ }` or quotes, in a pipeline
|
|
614
|
+
* stage, after `||` or followed by one before the command, ending a background list, behind a
|
|
615
|
+
* prefix (`if`, `then`, `command`, `VAR=x`), after `&&` in an earlier list, an option or `-`/`+N` —
|
|
616
|
+
* makes it {@link UNKNOWN_DIR} (or a folder below it), which resolves to no folder, so the push asks.
|
|
617
|
+
*/
|
|
618
|
+
dir: string | null
|
|
619
|
+
/** For merges and releases: which forge CLI. */
|
|
620
|
+
forge?: 'glab' | 'gh'
|
|
621
|
+
/** For publishes and deploys: which tool. */
|
|
622
|
+
tool?: string
|
|
623
|
+
/**
|
|
624
|
+
* For pushes: every git invocation earlier on the line as a step ({@link gitStep}), child shells and
|
|
625
|
+
* command substitutions included: any of them may have moved what the facts measured. A git step run
|
|
626
|
+
* in a folder other than {@link dir}, or in an unknown one, is recorded as `<step> [other folder]`: its
|
|
627
|
+
* hooks and its configuration are not the ones {@link PushFacts.hooks} and {@link PushFacts.repoConfig}
|
|
628
|
+
* measured (`git -C ../b diff` runs ../b's `diff.external`, #1573 review).
|
|
629
|
+
*/
|
|
630
|
+
gitBefore?: string[]
|
|
631
|
+
/**
|
|
632
|
+
* For pushes: some earlier command on the line is not a git call — in this shell, a child shell or a
|
|
633
|
+
* command substitution (`gh pr checkout 5 && …`, `./switch.sh; …`, `x=$(./x)`) — so it may have moved
|
|
634
|
+
* HEAD or written the push configuration (`printf … >> .git/config`): every push form behind it asks.
|
|
635
|
+
* `cd`/`pushd`/`popd` (read as {@link dir}) and a bare assignment (`x=1`) run no command and do not set it.
|
|
636
|
+
* Also set by an earlier output redirection on any segment, git steps included ({@link writesOutput}):
|
|
637
|
+
* `git log --format=… >> .git/config`, `cd . > .git/HEAD`, `exec 3>>.git/config`.
|
|
638
|
+
*/
|
|
639
|
+
otherBefore?: true
|
|
640
|
+
/**
|
|
641
|
+
* For pushes: an earlier `git commit` on the line signs explicitly (`-S`, `-S<key>`, `--gpg-sign`, a short
|
|
642
|
+
* option bundle holding `S`; the last of these and `--no-gpg-sign` wins), so it runs `gpg.program` whatever
|
|
643
|
+
* `commit.gpgSign` says ({@link repoConfigStandard}). `tag -s` and `merge -S` are no local step.
|
|
644
|
+
*/
|
|
645
|
+
signsBefore?: true
|
|
646
|
+
/**
|
|
647
|
+
* For pushes: git configuration the push runs with that the measured facts did not see — each
|
|
648
|
+
* `-c` / `--config-env` entry as written (`key=value`), `--git-dir` / `--work-tree` / `--namespace`
|
|
649
|
+
* as `--option=value`, every other global option as written, the names of `GIT_CONFIG*` /
|
|
650
|
+
* `GIT_DIR`-style variables anywhere on the line, and the name of every variable assigned, exported,
|
|
651
|
+
* declared or unset on the line (in front of the push or as a statement) that is not
|
|
652
|
+
* {@link HARMLESS_VAR} (`HOME=x` moves the config git reads), plus `source`/`.`/`alias`/`hash` as written.
|
|
653
|
+
*/
|
|
654
|
+
gitConfig?: string[]
|
|
655
|
+
/** For pushes: the destination is not read from git's own words (an unreadable line, a forge API ref write): always asks. */
|
|
656
|
+
opaque?: true
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// stepDirs: the folder each git step of gitBefore ran in, by its index.
|
|
660
|
+
type WalkState = { dir: string | null; gitBefore: string[]; stepDirs: Map<number, string | null>; otherBefore: boolean; signs: boolean; gitEnv: Set<string> }
|
|
661
|
+
|
|
662
|
+
// Variables that route a push past what the facts measured: extra config, another repository.
|
|
663
|
+
const GIT_ENV = /(?<![\w])(GIT_(?:CONFIG\w*|DIR|WORK_TREE|NAMESPACE|COMMON_DIR))=/g
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The folder a push gets when a `cd` before it may or may not have run ({@link Outward.dir}): a path
|
|
667
|
+
* below `/dev/null`, a device and never a directory, so `cd` into it (or into any folder below it)
|
|
668
|
+
* fails on every system and the wiring resolves no folder — the push asks.
|
|
669
|
+
*/
|
|
670
|
+
export const UNKNOWN_DIR = '/dev/null/so-hud-unknown-folder'
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The variable names an assignment before a push may set without asking — an allowlist (#1569 M2a):
|
|
674
|
+
* a single lowercase letter, or a name an agent's one-liner uses to hold a value. bash gives no
|
|
675
|
+
* lowercase name a meaning; none of these is a zsh special parameter (zshparam(1), zsh/parameter —
|
|
676
|
+
* `path`, `cdpath`, `commands`, `aliases` are, and stay out), and none is read by git, ssh or curl.
|
|
677
|
+
* Also one or two uppercase characters (`D`, `WT`, `L1`, `TZ`): agents hold paths in them, and no
|
|
678
|
+
* shell gives such a name a meaning (measured 2026-10-12: zsh 5.9 `${(k)parameters}`, 185 names, and
|
|
679
|
+
* bash `compgen -v` hold none matching `^[A-Z][A-Z0-9]?$`); `TZ` only changes how times print. A later
|
|
680
|
+
* `$D` after `cd` or in the push's own words is not read by the rules and asks on its own.
|
|
681
|
+
* Everything else asks: `HOME`, `XDG_*`, `GIT_*`, `PATH`, `PREFIX`, `CDPATH`, `BASH_ENV` … change
|
|
682
|
+
* which config git reads, which git runs, or where `cd` goes.
|
|
683
|
+
*/
|
|
684
|
+
const HARMLESS_VAR = /^(?:[a-z]|rc|out|msg|sha|ref|head|base|branch|res|ok|tmp|[A-Z][A-Z0-9]?)$/
|
|
685
|
+
// Statements that set or remove variables by name, and those that change how the shell finds a command.
|
|
686
|
+
const DECLARERS = new Set(['export', 'declare', 'typeset', 'local', 'readonly', 'unset', 'let'])
|
|
687
|
+
// Builtins that set the variables they name without `=` (#1571): `read -r GIT_CONFIG_COUNT … <<< "…"`, `printf -v HOME`,
|
|
688
|
+
// `mapfile`/`readarray`, `getopts`, zsh `vared`. Every word that starts like a name counts (a prompt word too: that only asks).
|
|
689
|
+
const NAME_SETTERS = new Set(['read', 'printf', 'mapfile', 'readarray', 'getopts', 'vared'])
|
|
690
|
+
const SHELL_STATE = new Set(['source', '.', 'alias', 'hash'])
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Every action with an effect outside, one per segment: `git push origin feat && git push origin main`
|
|
694
|
+
* holds two, and a skippable first one must not let the second run (review R1, gate-host). Command
|
|
695
|
+
* lines run by `bash -c`, `sh -c`, `zsh -c`, `eval` and a runner's `-c` are read too, up to depth 3;
|
|
696
|
+
* a line this cannot read adds one fail-closed hit from {@link unreadableOutward}.
|
|
697
|
+
*/
|
|
698
|
+
export function classifyAllOutward(command: string): Outward[] {
|
|
699
|
+
const hits: Outward[] = []
|
|
700
|
+
const line = dataView(joinContinuations(command))
|
|
701
|
+
walkOutward(line, 0, { dir: null, gitBefore: [], stepDirs: new Map(), otherBefore: false, signs: false, gitEnv: new Set() }, hits)
|
|
702
|
+
const kind = unreadKind(line) ?? unformedKind(line, 0)
|
|
703
|
+
if (kind !== null) hits.push({ kind, label: UNREADABLE_LABEL, args: [], dir: null, opaque: true })
|
|
704
|
+
return hits
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---- The fail-closed fallback: an outward word no hit was formed for (#1570) ---
|
|
708
|
+
|
|
709
|
+
// Commands that never run what they are given: their words are data (`echo "… git push …"`, `grep -rn publish`).
|
|
710
|
+
// Not here: `sed` (GNU `e`), `awk` (`system()`), `find` (`-ok`), `xargs`, any shell or interpreter, and every
|
|
711
|
+
// command not listed — an allowlist, so a wrapper this does not know (`flock`, `stdbuf`, `doas`) is read as code.
|
|
712
|
+
const DATA_COMMANDS = new Set(['echo', 'printf', 'cat', 'grep', 'egrep', 'fgrep', 'rg', 'head', 'tail', 'wc', 'ls', 'sort', 'uniq', 'cut', 'tr', 'diff', 'cmp', 'less', 'more', 'file', 'stat', 'du', 'df', 'test', '[', '[[', ':', 'true', 'false', 'sleep', 'wait', 'date', 'pwd', 'which', 'type', 'whence', 'mkdir', 'touch', 'rm', 'rmdir', 'cp', 'mv', 'ln', 'chmod', 'basename', 'dirname', 'realpath', 'readlink', 'jq', 'yq', 'column', 'nl', 'fold', 'cd', 'pushd', 'popd', 'export', 'declare', 'typeset', 'local', 'readonly', 'unset', 'set', 'shift', 'return', 'exit', 'break', 'continue', 'ps', 'pgrep', 'pkill', 'kill', 'lsof'])
|
|
713
|
+
// Commands that read a pipe as data and never run it; a pipe into anything else (`| sh`, `| awk`, `| sed`) runs its input.
|
|
714
|
+
const PIPE_DATA = new Set(['cat', 'grep', 'egrep', 'fgrep', 'rg', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'less', 'more', 'jq', 'yq', 'tee', 'column', 'nl', 'fold', 'diff', 'cmp', 'base64', 'shasum', 'sha256sum', 'md5', 'od', 'xxd', 'hexdump', 'strings'])
|
|
715
|
+
// The forge subcommands whose words are titles, bodies and filters; `merge`, `release create` and `api` writes are read as hits.
|
|
716
|
+
const FORGE_TEXT = new Set(['issue', 'mr', 'pr', 'api', 'label', 'search', 'browse', 'status', 'auth', 'config'])
|
|
717
|
+
const FORGE_READ_ACTIONS = new Set(['view', 'list', 'download', 'status', 'watch', 'trace', 'get', 'clone'])
|
|
718
|
+
// The tools whose outward subcommands the classifier knows exactly: a `--dry-run` of theirs, and any vercel call that is
|
|
719
|
+
// no production deploy, was read and judged.
|
|
720
|
+
const OUTWARD_TOOLS = new Set(['npm', 'pnpm', 'yarn', 'cargo', 'changeset', 'vercel', 'supabase', 'fly', 'flyctl', 'railway'])
|
|
721
|
+
// Interpreters whose inline code (`node -e "…"`, `python3 - <<'EOF'`) reaches git only through a process API: without
|
|
722
|
+
// one of {@link SPAWN_API} on the line, their code is data to the fallback (an analysis script that mentions `git push`).
|
|
723
|
+
// Ceiling: a process API spelled so this does not match (`require('child' + '_process')`), or a module the code loads.
|
|
724
|
+
const GATED_INTERPRETERS = new Set(['node', 'deno', 'bun', 'tsx', 'python', 'python3', 'osascript', 'perl', 'ruby'])
|
|
725
|
+
// perl and ruby also run a backtick string, and a backtick is ordinary text to node (a template literal).
|
|
726
|
+
const BACKTICK_RUNNERS = new Set(['perl', 'ruby'])
|
|
727
|
+
const SPAWN_API = /\bsystem\s*\(|\bqx\b|\bpopen\b|\bOpen3\b|%x[({[]|child_process|\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork)\s*\(|\bexeca\b|\$`|\bBun\.(?:spawn|\$)|\bDeno\.(?:run|Command)|\bzx\b|\bsubprocess\b|\bos\.(?:system|popen|exec|spawn|posix_spawn)|\bpty\b|\bplumbum\b|\bpexpect\b|do shell script/
|
|
728
|
+
|
|
729
|
+
// What a command word from a variable may hide behind it: an outward subcommand whose tool is unreadable (`${X}t push`).
|
|
730
|
+
const OUTWARD_SUBWORDS = new Set(['push', 'publish', 'merge', 'accept', 'deploy', 'promote', 'rollback', 'up', '--prod'])
|
|
731
|
+
|
|
732
|
+
// git subcommands that run a shell command given as an argument (`git rebase -x "…"`, `git submodule foreach '…'`): their
|
|
733
|
+
// words are code, not judged.
|
|
734
|
+
const GIT_RUNS_SHELL = new Set(['rebase', 'bisect', 'submodule', 'filter-branch', 'difftool', 'mergetool'])
|
|
735
|
+
|
|
736
|
+
/** Whether the classifier read this command and judged it, so its words need no second look ({@link unformedKind}). */
|
|
737
|
+
function judged(cmd: string, args: string[]): boolean {
|
|
738
|
+
if (classifyWords(cmd, args, null) !== null || cmd === 'git-push') return true
|
|
739
|
+
if (cmd === 'git') return !GIT_RUNS_SHELL.has(readGit(args, null).sub ?? '')
|
|
740
|
+
if (cmd === 'gh' || cmd === 'glab' || /^glab-[a-z0-9-]+$/.test(cmd)) {
|
|
741
|
+
return FORGE_TEXT.has(args[0] ?? '') || FORGE_READ_ACTIONS.has(args[1] ?? '')
|
|
742
|
+
}
|
|
743
|
+
if (!OUTWARD_TOOLS.has(cmd)) return false
|
|
744
|
+
// `npm help publish`, `npm i publish-x`: a subcommand the publish reader rules out.
|
|
745
|
+
const pm = (cmd === 'npm' || cmd === 'pnpm' || cmd === 'yarn') && PM_NOT_PUBLISHING.has(positionals(args, PM_VALUED)[0] ?? '')
|
|
746
|
+
return cmd === 'vercel' || pm || isDryRun(args, PM_VALUED, false)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** The segment with each inner command line put back where its placeholder stands. */
|
|
750
|
+
function withInners(text: string, inners: readonly Inner[]): string {
|
|
751
|
+
return text.replace(/__SOHUD_INNER_(\d+)__/g, (m: string, n: string) => inners[Number(n)]?.command ?? m)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* The fail-closed fallback after the walker (#1570): the outward kind of the first segment that names an outward
|
|
756
|
+
* action ({@link classKindIn}) but formed no hit and was not judged ({@link judged}); null when there is none. A
|
|
757
|
+
* denylist of spellings does not converge (each review found new wrappers: `time -p`, `flock … bash -c`, `nohup --`,
|
|
758
|
+
* `sh <<<'…'`, `python3 -c`, `case x in a|x)`), so this asks for every segment it cannot account for instead. Not read
|
|
759
|
+
* as code: a segment whose command is in {@link DATA_COMMANDS} (unless its output is piped into a command outside
|
|
760
|
+
* {@link PIPE_DATA}: `echo git push origin main | sh`), one with no command (`msg="deploy"`), and what {@link dataView}
|
|
761
|
+
* removed. A placeholder of a cut-out `bash -c '…'` that is not the first word (`flock /tmp/l bash -c "…"`) counts
|
|
762
|
+
* as code; a first-word one is read as its own line. Inline code of a {@link GATED_INTERPRETERS} interpreter counts
|
|
763
|
+
* only on a line with a process API ({@link SPAWN_API}). A git subcommand from a variable (`git ${X}sh`) always asks,
|
|
764
|
+
* a command word from one asks when its subcommand is an outward word ({@link OUTWARD_SUBWORDS}: `${X}t push`).
|
|
765
|
+
*/
|
|
766
|
+
function unformedKind(line: string, depth: number): OutwardKind | null {
|
|
767
|
+
const { text, inners } = extractInner(line, depth)
|
|
768
|
+
const joined = joinContinuations(text)
|
|
769
|
+
const segs = segmentsOf(joined)
|
|
770
|
+
const spawns = SPAWN_API.test(joined)
|
|
771
|
+
const openers = quoteOpeners(joined, spawns)
|
|
772
|
+
const words = segs.map(seg => readCommand(seg.text, true).words)
|
|
773
|
+
// Per segment, the first segment from it on whose cut is outside every quote (or not followed): where its command ends.
|
|
774
|
+
const realEnd = new Int32Array(segs.length)
|
|
775
|
+
for (let j = segs.length - 1; j >= 0; j -= 1) {
|
|
776
|
+
const cut = openers[(segs[j]?.start ?? 0) + (segs[j]?.text.length ?? 0)] ?? UNKNOWN_QUOTE
|
|
777
|
+
realEnd[j] = cut < 0 || j === segs.length - 1 ? j : (realEnd[j + 1] ?? j)
|
|
778
|
+
}
|
|
779
|
+
// The segment holding index `at` (the last one starting at or before it).
|
|
780
|
+
const segAt = (at: number): number => {
|
|
781
|
+
let lo = 0
|
|
782
|
+
let hi = segs.length - 1
|
|
783
|
+
while (lo < hi) {
|
|
784
|
+
const mid = (lo + hi + 1) >> 1
|
|
785
|
+
if ((segs[mid]?.start ?? 0) <= at) lo = mid
|
|
786
|
+
else hi = mid - 1
|
|
787
|
+
}
|
|
788
|
+
return lo
|
|
789
|
+
}
|
|
790
|
+
for (let k = 0; k < segs.length; k += 1) {
|
|
791
|
+
const seg = segs[k]
|
|
792
|
+
if (seg === undefined) continue
|
|
793
|
+
const opener = openers[seg.start] ?? UNKNOWN_QUOTE
|
|
794
|
+
if (opener === HEREDOC_DATA) continue
|
|
795
|
+
// A segment cut out of a quoted word (a commit body's line, `-m "a; b"`) is that word's command's argument —
|
|
796
|
+
// unless an `eval` parses that word again.
|
|
797
|
+
const quotedIn = opener >= 0 ? segAt(opener) : k
|
|
798
|
+
const owner = EVAL_WORD.test(segs[quotedIn]?.text ?? '') ? k : quotedIn
|
|
799
|
+
const [first, ...args] = words[owner] ?? []
|
|
800
|
+
if (first === undefined) continue
|
|
801
|
+
const inner = innerAt(first, inners)
|
|
802
|
+
if (inner !== null) {
|
|
803
|
+
if (owner !== k) continue
|
|
804
|
+
const kind = unformedKind(inner.command, depth + 1)
|
|
805
|
+
if (kind !== null) return kind
|
|
806
|
+
continue
|
|
807
|
+
}
|
|
808
|
+
const cmd = baseName(first)
|
|
809
|
+
if (owner === k && cmd === 'git' && (readGit(args, null).sub ?? '').includes('$')) return 'push'
|
|
810
|
+
if (judged(cmd, args)) continue
|
|
811
|
+
// The command ends at the first cut outside its quotes: a `|` inside `grep -E "a|b"` is no pipe.
|
|
812
|
+
const last = realEnd[owner] ?? owner
|
|
813
|
+
const next = words[last + 1] ?? []
|
|
814
|
+
const piped = PIPE.has(segs[last]?.after ?? '') && !PIPE_DATA.has(baseName(next[0] ?? '')) && !judged(baseName(next[0] ?? ''), next.slice(1))
|
|
815
|
+
if (DATA_COMMANDS.has(cmd) && !piped) continue
|
|
816
|
+
if (GATED_INTERPRETERS.has(cmd) && !spawns && !(BACKTICK_RUNNERS.has(cmd) && joined.includes('`'))) continue
|
|
817
|
+
const code = withInners(seg.text, inners)
|
|
818
|
+
const kind = classKindIn(code)
|
|
819
|
+
if (kind !== null) return kind
|
|
820
|
+
if (owner === k && first.includes('$') && args.slice(0, 2).some(word => OUTWARD_SUBWORDS.has(word))) return 'push'
|
|
821
|
+
}
|
|
822
|
+
return null
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// A command that may run a file written earlier on the line, in command position (line start or after `;`, `&`, `|`,
|
|
826
|
+
// `(`, `{`, a newline, `then`/`do`): a shell or interpreter, `source`/`.`, `exec`, `xargs`, or a path like `./x`, `/tmp/x.sh`.
|
|
827
|
+
const RUNS_FILE = /(?:^|[;&|(){}\n]|\b(?:then|do|else)\s)\s*(?:sudo\s+|env\s+|nohup\s+|exec\s+)*(sh|bash|zsh|dash|ksh|fish|source|\.|python3?|node|deno|bun|tsx|perl|ruby|exec|xargs|\.{0,2}\/\S+)(?=[\s;&|()]|$)/g
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Whether a line may run a file it wrote ({@link RUNS_FILE}): a runner that is no {@link GATED_INTERPRETERS} one, or a
|
|
831
|
+
* gated one on a line that can start a process (`spawns`) — `cat > x.mjs <<'EOF' … EOF; node x.mjs` with no process API
|
|
832
|
+
* runs no git.
|
|
833
|
+
*/
|
|
834
|
+
function runsWrittenFile(line: string, spawns: boolean): boolean {
|
|
835
|
+
for (const m of line.matchAll(RUNS_FILE)) {
|
|
836
|
+
const runner = m[1] ?? ''
|
|
837
|
+
if (spawns || !GATED_INTERPRETERS.has(runner) || (BACKTICK_RUNNERS.has(runner) && line.includes('`'))) return true
|
|
838
|
+
}
|
|
839
|
+
return false
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Values of {@link quoteOpeners} that are no index: outside every quote, not followed (after a construct that nests its
|
|
843
|
+
// own quoting), and inside the body of a heredoc read as text.
|
|
844
|
+
const NO_QUOTE = -1
|
|
845
|
+
const UNKNOWN_QUOTE = -2
|
|
846
|
+
const HEREDOC_DATA = -3
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Per index of `line`: where the quote it sits inside opened, {@link NO_QUOTE}, {@link HEREDOC_DATA} inside the body of
|
|
850
|
+
* a heredoc with a literal body that `cat` or `tee` reads (also into a file, on a line that runs no file
|
|
851
|
+
* ({@link runsWrittenFile}): the walker still reads those lines, this only keeps the fallback from asking for them) or a
|
|
852
|
+
* {@link GATED_INTERPRETERS} one does on a line without `spawns`,
|
|
853
|
+
* or {@link UNKNOWN_QUOTE} from the first construct whose quoting this does not follow (a backtick, `${…}` other than
|
|
854
|
+
* `${name}`, `$'`, `<(`, `>(`, any other heredoc). Quotes are followed
|
|
855
|
+
* across newlines (a multi-line commit message is one word) and into `$(…)`, whose body starts unquoted and whose `)`
|
|
856
|
+
* restores the quote around it; a `case` or a comment inside it stops the tracking (their `)` would end it early). Every
|
|
857
|
+
* `$(…)` body is read by {@link unreadRegions} as well. One pass.
|
|
858
|
+
*/
|
|
859
|
+
function quoteOpeners(line: string, spawns: boolean): Int32Array {
|
|
860
|
+
const owner = new Int32Array(line.length + 1).fill(UNKNOWN_QUOTE)
|
|
861
|
+
let quote: '"' | "'" | null = null
|
|
862
|
+
let opened = NO_QUOTE
|
|
863
|
+
let segStart = 0
|
|
864
|
+
// One frame per open `$(`: the quote around it, and the plain `(` opened inside it, so its `)` is found.
|
|
865
|
+
const frames: { quote: '"' | "'" | null; opened: number; parens: number }[] = []
|
|
866
|
+
let i = 0
|
|
867
|
+
const here = (): number => (quote === null ? NO_QUOTE : opened)
|
|
868
|
+
for (; i < line.length; i += 1) {
|
|
869
|
+
const ch = line[i] ?? ''
|
|
870
|
+
owner[i] = here()
|
|
871
|
+
if (quote === "'") {
|
|
872
|
+
if (ch === "'") quote = null
|
|
873
|
+
continue
|
|
874
|
+
}
|
|
875
|
+
if (ch === '\\') {
|
|
876
|
+
i += 1
|
|
877
|
+
if (i < line.length) owner[i] = here()
|
|
878
|
+
continue
|
|
879
|
+
}
|
|
880
|
+
if (ch === '`') break
|
|
881
|
+
if (ch === '$') {
|
|
882
|
+
const next = line[i + 1]
|
|
883
|
+
if (next === "'") break
|
|
884
|
+
if (next === '(') {
|
|
885
|
+
frames.push({ quote, opened, parens: 0 })
|
|
886
|
+
quote = null
|
|
887
|
+
owner[i + 1] = NO_QUOTE
|
|
888
|
+
i += 1
|
|
889
|
+
segStart = i + 1
|
|
890
|
+
} else if (next === '{') {
|
|
891
|
+
PLAIN_BRACE.lastIndex = i
|
|
892
|
+
if (!PLAIN_BRACE.test(line)) break
|
|
893
|
+
}
|
|
894
|
+
continue
|
|
895
|
+
}
|
|
896
|
+
if (quote === '"') {
|
|
897
|
+
if (ch === '"') quote = null
|
|
898
|
+
continue
|
|
899
|
+
}
|
|
900
|
+
const frame = frames.at(-1)
|
|
901
|
+
// Inside `$(…)` a `case` pattern's `)` or a comment's `)` would end the frame early and shift the quotes after it.
|
|
902
|
+
if (frame !== undefined && (i === 0 || WORD_BREAK.test(line[i - 1] ?? '')) && (ch === '#' || /^case(?=[\s;&|()<>]|$)/.test(line.slice(i, i + 5)))) break
|
|
903
|
+
if (ch === '"' || ch === "'") {
|
|
904
|
+
quote = ch
|
|
905
|
+
opened = i
|
|
906
|
+
} else if ((ch === '<' || ch === '>') && line[i + 1] === '(') break
|
|
907
|
+
else if (ch === '(' && frame !== undefined) frame.parens += 1
|
|
908
|
+
else if (ch === ')' && frame !== undefined && frame.parens > 0) frame.parens -= 1
|
|
909
|
+
else if (ch === ')' && frame !== undefined) {
|
|
910
|
+
frames.pop()
|
|
911
|
+
quote = frame.quote
|
|
912
|
+
opened = frame.opened
|
|
913
|
+
owner[i] = here()
|
|
914
|
+
} else if (ch === '<' && line[i + 1] === '<' && line[i + 2] === '<') {
|
|
915
|
+
owner[i + 1] = NO_QUOTE
|
|
916
|
+
owner[i + 2] = NO_QUOTE
|
|
917
|
+
i += 2
|
|
918
|
+
} else if (ch === '<' && line[i + 1] === '<') {
|
|
919
|
+
HEREDOC_OP.lastIndex = i
|
|
920
|
+
const op = HEREDOC_OP.exec(line)
|
|
921
|
+
const delimiter = op?.[2] ?? op?.[3] ?? op?.[4] ?? op?.[5]
|
|
922
|
+
const nl = line.indexOf('\n', HEREDOC_OP.lastIndex)
|
|
923
|
+
if (op === null || delimiter === undefined || nl === -1) break
|
|
924
|
+
const rest = line.slice(HEREDOC_OP.lastIndex, nl)
|
|
925
|
+
const end = HEREDOC_REST.test(rest) ? heredocEnd(line, nl + 1, delimiter, op[1] === '-') : -1
|
|
926
|
+
if (end === -1) break
|
|
927
|
+
const readerText = `${line.slice(segStart, i)} ${rest}`
|
|
928
|
+
const reader = readCommand(readerText, true).words
|
|
929
|
+
const tool = baseName(reader[0] ?? '')
|
|
930
|
+
const literal = op[5] === undefined || inertBody(line.slice(nl + 1, end))
|
|
931
|
+
// A body `cat`/`tee` writes to a file is code when the line may run a file (`cat > f <<'EOF' … EOF; sh f`).
|
|
932
|
+
const written = (tool === 'cat' || tool === 'tee') && !runsWrittenFile(line, spawns)
|
|
933
|
+
if (!literal || !(written || readsAsText(reader, writesOutput(readerText)) || (GATED_INTERPRETERS.has(tool) && !spawns && !(BACKTICK_RUNNERS.has(tool) && line.includes('`'))))) break
|
|
934
|
+
owner.fill(NO_QUOTE, i, nl + 1)
|
|
935
|
+
owner.fill(HEREDOC_DATA, nl + 1, end)
|
|
936
|
+
i = end - 1
|
|
937
|
+
} else if (ch === ';' || ch === '&' || ch === '|' || ch === '\n' || ch === '(' || ch === ')') segStart = i + 1
|
|
938
|
+
}
|
|
939
|
+
if (i >= line.length) owner[line.length] = here()
|
|
940
|
+
return owner
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ---- Shapes this parser does not read (fail-closed) --------------------------
|
|
944
|
+
|
|
945
|
+
const UNREADABLE_LABEL = 'nicht lesbare Befehlsform'
|
|
946
|
+
|
|
947
|
+
// Constructs whose effect the word reader above cannot see. Each is judged on the segment it sits in.
|
|
948
|
+
const UNREAD_CONSTRUCTS = [
|
|
949
|
+
/\bxargs\b/g,
|
|
950
|
+
/\bssh\s/g,
|
|
951
|
+
/\bwatch\s/g,
|
|
952
|
+
// The lookbehind anchors the name at a word start: without it the engine retries from every
|
|
953
|
+
// character of a long word, quadratic (1524 ms on 50k chars, Lead review of !132).
|
|
954
|
+
/(?<![\w.-])[\w.-]+\s*\(\)\s*\{/g,
|
|
955
|
+
/\bfunction\s+[\w.-]+/g,
|
|
956
|
+
/\/(?:bash|sh|zsh|dash|ksh|env)(?=\s)/g,
|
|
957
|
+
/\$'/g,
|
|
958
|
+
/\balias\./gi,
|
|
959
|
+
]
|
|
960
|
+
// Constructs of a head and a later tail in the same run (no `;`, `&`, `|` or newline between):
|
|
961
|
+
// `find … -exec`, `env … -S`, `case … in`. Matched as two linear scans ({@link headsWithTail}): as one
|
|
962
|
+
// regex `head[^\n;&|]*tail` each head re-scanned the run to its end, quadratic (`env -i` ×N).
|
|
963
|
+
const UNREAD_PAIRS: readonly (readonly [head: RegExp, tail: RegExp])[] = [
|
|
964
|
+
[/\bfind\b/g, /\s-exec(?:dir)?\b/g],
|
|
965
|
+
[/\benv\b/g, /\s(?:-[A-Za-z]*S|--split-string)/g],
|
|
966
|
+
[/\bcase\b/g, /\bin\b/g],
|
|
967
|
+
]
|
|
968
|
+
// A shell given a command with -c — a shell name followed by whitespace, and later in the same run
|
|
969
|
+
// without `;`, `&` or `|` a word of letters holding `c` (`-c`, `-lc`) — unreadable when INNER cannot
|
|
970
|
+
// cut it at the same place. Matched as two linear scans ({@link shellCStarts}): as one regex with a
|
|
971
|
+
// lazy word run it retried from every shell name to the run's end, 680 ms for 10k `bash ` words.
|
|
972
|
+
const SHELL_NAME = /(?<![\w./-])(?:bash|sh|zsh|dash|ksh)(?=\s)/g
|
|
973
|
+
// `-(?=[A-Za-z]*c)[A-Za-z]+` is `-[A-Za-z]*c[A-Za-z]*` without its quadratic backtracking on a long word.
|
|
974
|
+
const C_OPTION = /\s-(?=[A-Za-z]*c)[A-Za-z]+(?=\s|$)/g
|
|
975
|
+
|
|
976
|
+
/** The `[start, end)` of each run of the line between the characters `cut` matches. */
|
|
977
|
+
function runsOf(line: string, cut: RegExp): Span[] {
|
|
978
|
+
const runs: Span[] = []
|
|
979
|
+
let start = 0
|
|
980
|
+
for (const m of line.matchAll(cut)) {
|
|
981
|
+
runs.push([start, m.index])
|
|
982
|
+
start = m.index + 1
|
|
983
|
+
}
|
|
984
|
+
runs.push([start, line.length])
|
|
985
|
+
return runs
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** The start of each `head` match with a `tail` match starting at or after its end in the same run: two linear scans. */
|
|
989
|
+
function headsWithTail(line: string, runs: readonly Span[], head: RegExp, tail: RegExp): number[] {
|
|
990
|
+
const lastTail = new Map<number, number>()
|
|
991
|
+
for (const m of line.matchAll(tail)) lastTail.set(spanIndexAt(runs, m.index), m.index)
|
|
992
|
+
const starts: number[] = []
|
|
993
|
+
for (const m of line.matchAll(head)) {
|
|
994
|
+
const t = lastTail.get(spanIndexAt(runs, m.index))
|
|
995
|
+
if (t !== undefined && t >= m.index + m[0].length) starts.push(m.index)
|
|
996
|
+
}
|
|
997
|
+
return starts
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Where a shell is given `-c`: a SHELL_NAME with a C_OPTION after it and no `;`, `&` or `|` between. */
|
|
1001
|
+
function shellCStarts(line: string): number[] {
|
|
1002
|
+
return headsWithTail(line, runsOf(line, /[;&|]/g), SHELL_NAME, C_OPTION)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
type Span = [start: number, end: number]
|
|
1006
|
+
|
|
1007
|
+
/** The `[start, end)` of every segment of the line, cut at the separators splitSegments cuts at, in order. */
|
|
1008
|
+
function segmentSpans(line: string): Span[] {
|
|
1009
|
+
const spans: Span[] = []
|
|
1010
|
+
let start = 0
|
|
1011
|
+
for (const m of markBackground(line).matchAll(SEPARATOR)) {
|
|
1012
|
+
spans.push([start, m.index])
|
|
1013
|
+
start = m.index + m[0].length
|
|
1014
|
+
}
|
|
1015
|
+
spans.push([start, line.length])
|
|
1016
|
+
return spans
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** The index in `spans` of the segment holding `index` (binary search: the last segment starting at or before it). */
|
|
1020
|
+
function spanIndexAt(spans: readonly Span[], index: number): number {
|
|
1021
|
+
let lo = 0
|
|
1022
|
+
let hi = spans.length - 1
|
|
1023
|
+
while (lo < hi) {
|
|
1024
|
+
const mid = (lo + hi + 1) >> 1
|
|
1025
|
+
if ((spans[mid]?.[0] ?? 0) <= index) lo = mid
|
|
1026
|
+
else hi = mid - 1
|
|
1027
|
+
}
|
|
1028
|
+
return lo
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* The `[start, end)` body of every `$(…)` (parentheses balanced, quotes not honoured) and after every
|
|
1033
|
+
* backtick (to the next one), each to the end of the line when unclosed. One pass with a stack, so a
|
|
1034
|
+
* line of 25,000 unclosed `$(` costs no more than one of plain words.
|
|
1035
|
+
*/
|
|
1036
|
+
function substitutionSpans(line: string): Span[] {
|
|
1037
|
+
const spans: Span[] = []
|
|
1038
|
+
// Per open parenthesis: the index in `spans` of the `$(` it opened, or -1 for a plain `(`.
|
|
1039
|
+
const open: number[] = []
|
|
1040
|
+
let lastTick = -1
|
|
1041
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
1042
|
+
const ch = line[i]
|
|
1043
|
+
if (ch === '$' && line[i + 1] === '(') {
|
|
1044
|
+
open.push(spans.push([i + 2, line.length]) - 1)
|
|
1045
|
+
i += 1
|
|
1046
|
+
} else if (ch === '(') open.push(-1)
|
|
1047
|
+
else if (ch === ')') {
|
|
1048
|
+
const k = open.pop()
|
|
1049
|
+
const span = k === undefined || k === -1 ? undefined : spans[k]
|
|
1050
|
+
if (span !== undefined) span[1] = i
|
|
1051
|
+
} else if (ch === '`') {
|
|
1052
|
+
const prev = lastTick === -1 ? undefined : spans[lastTick]
|
|
1053
|
+
if (prev !== undefined) prev[1] = i
|
|
1054
|
+
lastTick = spans.push([i + 1, line.length]) - 1
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return spans
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** The spans merged where they overlap or touch, sorted. */
|
|
1061
|
+
function mergeSpans(spans: Span[]): Span[] {
|
|
1062
|
+
const merged: Span[] = []
|
|
1063
|
+
for (const [start, end] of [...spans].sort((a, b) => a[0] - b[0])) {
|
|
1064
|
+
const last = merged.at(-1)
|
|
1065
|
+
if (last !== undefined && start <= last[1]) last[1] = Math.max(last[1], end)
|
|
1066
|
+
else merged.push([start, end])
|
|
1067
|
+
}
|
|
1068
|
+
return merged
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** The text with `$'…'` escapes decoded and every quote and backslash dropped, so `pu"sh"` and `$'\x70ush'` read as `push`. */
|
|
1072
|
+
function normalizeWords(text: string): string {
|
|
1073
|
+
return text
|
|
1074
|
+
.replace(/\$'((?:[^'\\]|\\.)*)'/g, (_m: string, body: string) =>
|
|
1075
|
+
body.replace(/\\x([0-9a-fA-F]{1,2})|\\u([0-9a-fA-F]{1,4})|\\([0-7]{1,3})|\\(.)/g, (_e: string, hex?: string, uni?: string, oct?: string, ch?: string) =>
|
|
1076
|
+
hex !== undefined ? String.fromCharCode(parseInt(hex, 16)) : uni !== undefined ? String.fromCharCode(parseInt(uni, 16)) : oct !== undefined ? String.fromCharCode(parseInt(oct, 8)) : (ch ?? '')))
|
|
1077
|
+
.replace(/["'\\]/g, '')
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** The outward kind a piece of unread text names, or null — wide on purpose: any `deploy` or `publish`, any forge `merge`/`release`. */
|
|
1081
|
+
function outwardWordIn(text: string): OutwardKind | null {
|
|
1082
|
+
const t = normalizeWords(text)
|
|
1083
|
+
const forge = /\b(?:gh|glab)\b/.test(t)
|
|
1084
|
+
if (/\bgit\b/.test(t) && /\bpush\b/.test(t)) return 'push'
|
|
1085
|
+
if (/\bpublish\b/.test(t)) return 'publish'
|
|
1086
|
+
if (forge && /\b(?:merge|accept)\b/.test(t)) return 'merge'
|
|
1087
|
+
if (/\bdeploy\b/.test(t) || /--prod(?:uction)?\b/.test(t)) return 'deploy'
|
|
1088
|
+
if (forge && /\brelease\b/.test(t)) return 'release'
|
|
1089
|
+
return classKindIn(text)
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// The words of unread text, split at blanks and at shell and code punctuation: `os.system(git push)` gives `git`, `push`;
|
|
1093
|
+
// `args.push(x)` stays one word, so a JS `.push(` next to a `git` word is no push.
|
|
1094
|
+
const CODE_WORD = /[^\s()[\]{}<>;|&,`]+/g
|
|
1095
|
+
// git's global options that take the next word as their value (`git -C dir push`).
|
|
1096
|
+
const GIT_VALUED = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path', '--attr-source'])
|
|
1097
|
+
const FORGE_VALUED = new Set(['-R', '--repo'])
|
|
1098
|
+
const NONE_VALUED: ReadonlySet<string> = new Set()
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* The outward kind of the first action the classifier holds ({@link classifyWords}) that a piece of unread text spells
|
|
1102
|
+
* as a tool word with its action word in subcommand position, options skipped — or null. `git [options] push`,
|
|
1103
|
+
* `git-push`; `gh|glab [options] mr|pr merge|accept`, `… release create`, `… api` with a method or field and a ref, branch or
|
|
1104
|
+
* merge path; npm/pnpm/yarn/changeset/cargo with a later `publish` word; vercel/vc with `--prod`, `--production`,
|
|
1105
|
+
* `promote` or `rollback`; `supabase db push`, `supabase db reset … --linked`, `supabase migration up … --linked`;
|
|
1106
|
+
* `fly deploy`; `railway up` (#1572 E3/E4). A piece holding `--dry-run` names no publish or supabase write. Linear: one
|
|
1107
|
+
* pass over the words, a later word looked up by its last index.
|
|
1108
|
+
*/
|
|
1109
|
+
function classKindIn(text: string): OutwardKind | null {
|
|
1110
|
+
const plain = normalizeWords(text)
|
|
1111
|
+
const words = plain.match(CODE_WORD) ?? []
|
|
1112
|
+
const last = new Map<string, number>()
|
|
1113
|
+
words.forEach((word, i) => last.set(word, i))
|
|
1114
|
+
const later = (i: number, ...names: string[]): boolean => names.some(name => (last.get(name) ?? -1) > i)
|
|
1115
|
+
const dry = last.has('--dry-run')
|
|
1116
|
+
// Judged for the whole piece, once: a slice per `api` word was quadratic on `gh api gh api …`.
|
|
1117
|
+
// An API call writes only with a method or a field (`gh api repos/o/r/git/refs/heads/main` alone reads).
|
|
1118
|
+
const apiWrites = /(?:^|\s)(?:-X\s*|--method[=\s]+)(?:POST|PUT|PATCH|DELETE)\b|(?:^|\s)(?:-[fF]|--(?:raw-)?field|--input)(?:[=\s]|\S)/i.test(plain)
|
|
1119
|
+
const refWrite = apiWrites && /refs\/heads\/|\bbranch=/.test(plain)
|
|
1120
|
+
const mergePath = apiWrites && /\/merges?\b/.test(plain)
|
|
1121
|
+
// The index of the first word from `j` that is no option (a valued option skips its value too).
|
|
1122
|
+
const skip = (j: number, valued: ReadonlySet<string>): number => {
|
|
1123
|
+
let k = j
|
|
1124
|
+
while ((words[k] ?? '').startsWith('-')) k += valued.has(words[k] ?? '') ? 2 : 1
|
|
1125
|
+
return k
|
|
1126
|
+
}
|
|
1127
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
1128
|
+
const tool = baseName(words[i] ?? '')
|
|
1129
|
+
if (tool === 'git-push') return 'push'
|
|
1130
|
+
if (tool === 'git' && words[skip(i + 1, GIT_VALUED)] === 'push') return 'push'
|
|
1131
|
+
if (tool === 'gh' || tool === 'glab') {
|
|
1132
|
+
const j = skip(i + 1, FORGE_VALUED)
|
|
1133
|
+
const [sub, action] = [words[j], words[j + 1]]
|
|
1134
|
+
if ((sub === 'mr' || sub === 'pr') && (action === 'merge' || action === 'accept')) return 'merge'
|
|
1135
|
+
if (sub === 'release' && action === 'create') return 'release'
|
|
1136
|
+
if (sub === 'api' && refWrite) return 'push'
|
|
1137
|
+
if (sub === 'api' && mergePath) return 'merge'
|
|
1138
|
+
}
|
|
1139
|
+
if (['npm', 'pnpm', 'yarn', 'changeset', 'cargo'].includes(tool) && later(i, 'publish') && !dry) return 'publish'
|
|
1140
|
+
if ((tool === 'vercel' || tool === 'vc') && later(i, '--prod', '--production', 'promote', 'rollback')) return 'deploy'
|
|
1141
|
+
if (tool === 'supabase' && !dry) {
|
|
1142
|
+
const j = skip(i + 1, NONE_VALUED)
|
|
1143
|
+
const [sub, action] = [words[j], words[j + 1]]
|
|
1144
|
+
if (sub === 'db' && (action === 'push' || (action === 'reset' && later(j, '--linked')))) return 'deploy'
|
|
1145
|
+
if (sub === 'migration' && action === 'up' && later(j, '--linked')) return 'deploy'
|
|
1146
|
+
}
|
|
1147
|
+
if ((tool === 'fly' || tool === 'flyctl') && words[skip(i + 1, NONE_VALUED)] === 'deploy') return 'deploy'
|
|
1148
|
+
if (tool === 'railway' && words[skip(i + 1, NONE_VALUED)] === 'up') return 'deploy'
|
|
1149
|
+
}
|
|
1150
|
+
return null
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* The pieces of a line this parser does not read: each construct's segment, a substitution's segment
|
|
1155
|
+
* joined with its body, and the whole line for a `$var` command. Each segment is read once however
|
|
1156
|
+
* many constructs it holds, and a substitution's segment and body, with every span overlapping them,
|
|
1157
|
+
* form one contiguous piece — a superset of reading each pair on its own, in linear time (a construct
|
|
1158
|
+
* per match over the whole line was quadratic: 317 ms at 8k characters of `$(`, about 12 s at 50k).
|
|
1159
|
+
*/
|
|
1160
|
+
function unreadRegions(line: string): string[] {
|
|
1161
|
+
const regions: string[] = []
|
|
1162
|
+
const segments = segmentSpans(line)
|
|
1163
|
+
const constructSegments = new Set<number>()
|
|
1164
|
+
for (const re of UNREAD_CONSTRUCTS) for (const m of line.matchAll(re)) constructSegments.add(spanIndexAt(segments, m.index))
|
|
1165
|
+
const runs = runsOf(line, /[\n;&|]/g)
|
|
1166
|
+
for (const [head, tail] of UNREAD_PAIRS) for (const at of headsWithTail(line, runs, head, tail)) constructSegments.add(spanIndexAt(segments, at))
|
|
1167
|
+
for (const at of shellCStarts(line)) {
|
|
1168
|
+
INNER_AT.lastIndex = at
|
|
1169
|
+
if (!INNER_AT.test(line)) constructSegments.add(spanIndexAt(segments, at))
|
|
1170
|
+
}
|
|
1171
|
+
for (const k of constructSegments) {
|
|
1172
|
+
const span = segments[k]
|
|
1173
|
+
if (span !== undefined) regions.push(line.slice(...span))
|
|
1174
|
+
}
|
|
1175
|
+
const bodies = substitutionSpans(line)
|
|
1176
|
+
const owners = new Set<number>()
|
|
1177
|
+
for (const [start] of bodies) owners.add(spanIndexAt(segments, start - 1))
|
|
1178
|
+
const pieces = [...bodies]
|
|
1179
|
+
for (const k of owners) {
|
|
1180
|
+
const span = segments[k]
|
|
1181
|
+
if (span !== undefined) pieces.push(span)
|
|
1182
|
+
}
|
|
1183
|
+
for (const [start, end] of mergeSpans(pieces)) regions.push(line.slice(start, end))
|
|
1184
|
+
// `p=push; git $p origin main`: a command or git subcommand from a variable is set anywhere on the
|
|
1185
|
+
// line, so the whole line is read — once (once per such segment re-read the line, quadratic).
|
|
1186
|
+
const expanded = (word: string | undefined) => word !== undefined && word.includes('$') && !word.startsWith('$(')
|
|
1187
|
+
for (const segment of splitSegments(line)) {
|
|
1188
|
+
const [first, ...args] = readCommand(segment, true).words
|
|
1189
|
+
if (expanded(first) || (baseName(first ?? '') === 'git' && expanded(readGit(args, null).sub))) {
|
|
1190
|
+
regions.push(line)
|
|
1191
|
+
break
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return regions
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* One fail-closed hit for a line holding a construct this parser does not read (`$(…)`, a backtick,
|
|
1199
|
+
* `xargs`, `find -exec`, `ssh`, `env -S`, `watch`, a function, `case`, a shell by path or one `-c`
|
|
1200
|
+
* cannot be cut from, `$'…'`, a git alias, a command from a variable) whose unread part names an
|
|
1201
|
+
* outward action; null otherwise. Its push always asks ({@link Outward.opaque}).
|
|
1202
|
+
*/
|
|
1203
|
+
export function unreadableOutward(command: string): Outward | null {
|
|
1204
|
+
const kind = unreadKind(dataView(joinContinuations(command)))
|
|
1205
|
+
return kind === null ? null : { kind, label: UNREADABLE_LABEL, args: [], dir: null, opaque: true }
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** The outward kind the first unread region of a line ({@link unreadRegions}, after {@link dataView}) names, or null. */
|
|
1209
|
+
function unreadKind(line: string): OutwardKind | null {
|
|
1210
|
+
for (const region of unreadRegions(line)) {
|
|
1211
|
+
const kind = outwardWordIn(region)
|
|
1212
|
+
if (kind !== null) return kind
|
|
1213
|
+
}
|
|
1214
|
+
return null
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function walkOutward(line: string, depth: number, state: WalkState, hits: Outward[]): void {
|
|
1218
|
+
const { text, inners } = extractInner(line, depth)
|
|
1219
|
+
for (const m of text.matchAll(GIT_ENV)) if (m[1] !== undefined) state.gitEnv.add(m[1])
|
|
1220
|
+
walkSegments(text, inners, depth, state, hits)
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Where a command substitution or process substitution opens: it runs before its segment's command.
|
|
1224
|
+
const SUBSTITUTION_OPEN = /\$\(|[<>]\(|`/
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* The git calls inside a segment's `$(…)`, backticks, `<(…)` or `>(…)` count as earlier git calls:
|
|
1228
|
+
* `echo $(git switch main) && git push` moves HEAD before the push runs. Read from the first opener to
|
|
1229
|
+
* the segment's end (over-reading only adds steps, which asks). Its pushes are not collected — the
|
|
1230
|
+
* line is held as unreadable for them ({@link unreadableOutward}) — and its `cd` ends with the
|
|
1231
|
+
* subshell. Past {@link MAX_DEPTH} any git word left counts as a step that is not local, and any
|
|
1232
|
+
* text left as an earlier command that is not git ({@link Outward.otherBefore}).
|
|
1233
|
+
*/
|
|
1234
|
+
function gitInSubstitution(segment: string, inners: readonly Inner[], depth: number, state: WalkState): void {
|
|
1235
|
+
const at = segment.search(SUBSTITUTION_OPEN)
|
|
1236
|
+
if (at === -1) return
|
|
1237
|
+
const body = segment.slice(at + (segment[at] === '`' ? 1 : 2))
|
|
1238
|
+
if (depth + 1 >= MAX_DEPTH) {
|
|
1239
|
+
if (/\bgit\b|__SOHUD_INNER_/.test(body)) state.gitBefore.push('$(…)')
|
|
1240
|
+
if (/\S/.test(body)) state.otherBefore = true
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
const dir = state.dir
|
|
1244
|
+
walkSegments(body, inners, depth + 1, state, [])
|
|
1245
|
+
state.dir = dir
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// The options of `git log`, `show` and `diff` that only choose what is printed — an allowlist (MR !133
|
|
1249
|
+
// HOCH 1): `--output` writes a file (`.git/HEAD`, `.git/config`), `--ext-diff` and `--textconv` run a
|
|
1250
|
+
// configured program, and an option git adds later is not here either. git takes none of these
|
|
1251
|
+
// commands' long options abbreviated (measured, git 2.56.0: `git show --outp=x` is refused).
|
|
1252
|
+
const DISPLAY_OPTIONS = new Set(['--oneline', '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--summary', '--cached', '--staged', '--no-color', '--decorate', '--graph', '--abbrev-commit', '-s', '--no-patch', '-p', '-u', '--patch', '-q', '--quiet', '--', '--no-ext-diff', '--no-textconv'])
|
|
1253
|
+
// …and these, with any value in the same word: a count (`-3`, `-n3`), `--format=`, `--pretty=`, `--color=`, `--max-count=`.
|
|
1254
|
+
const DISPLAY_VALUED = /^(?:-n?\d+$|--(?:format|pretty|color|max-count)=)/
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* How an earlier git call is recorded for {@link LOCAL_GIT}: its subcommand, with three exact
|
|
1258
|
+
* read-only forms spelled out — `branch --show-current`; `tag --list` for `tag -l` or `tag --list`
|
|
1259
|
+
* with no other word (a pattern or option is not needed for the everyday form, so none is admitted);
|
|
1260
|
+
* and `checkout -- <paths>` for `checkout` whose first word is `--` with at least one path after it
|
|
1261
|
+
* (it restores files and cannot switch the branch; `checkout main -- f` and a bare `checkout --` stay
|
|
1262
|
+
* `checkout`). `fetch` is the local step only with plain words (no option — `--upload-pack`, also as
|
|
1263
|
+
* `--upload=`, runs a program, `--set-upstream` moves the upstream — and no `ext::`-style URL, whose
|
|
1264
|
+
* transport runs a command where `protocol.ext.allow` permits it); `log`, `show` and `diff` only with every option in {@link DISPLAY_OPTIONS}
|
|
1265
|
+
* or {@link DISPLAY_VALUED} (a value given as its own word, `--output x`, is caught by its option). Any
|
|
1266
|
+
* other form is recorded as `<sub> [options]`, which is not local.
|
|
1267
|
+
*/
|
|
1268
|
+
/**
|
|
1269
|
+
* Whether a `git commit` with these words signs explicitly: the last of `-S`/`-S<key>`/`--gpg-sign[=<key>]`/a short
|
|
1270
|
+
* bundle holding `S` (`-aS`), or `--no-gpg-sign`, before `--`. Over-reads a bundle whose `S` is a value (`-mS`):
|
|
1271
|
+
* that only asks, and only where a `gpg.program` is set.
|
|
1272
|
+
*/
|
|
1273
|
+
function signsCommit(rest: readonly string[]): boolean {
|
|
1274
|
+
let signs = false
|
|
1275
|
+
for (const word of rest) {
|
|
1276
|
+
if (word === '--') break
|
|
1277
|
+
if (word === '--gpg-sign' || word.startsWith('--gpg-sign=') || (/^-[^-]/.test(word) && word.includes('S'))) signs = true
|
|
1278
|
+
else if (word === '--no-gpg-sign') signs = false
|
|
1279
|
+
}
|
|
1280
|
+
return signs
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// `git commit` options that open no editor, run no pager or diff filter and read no patch interactively — an allowlist
|
|
1284
|
+
// (#1573 input), matched as whole words; the `=`-forms of the valued ones are taken too. Not here: `-e`/`--edit`,
|
|
1285
|
+
// `-c`/`--reedit-message`, `-t`/`--template`, `-p`/`--patch`/`--interactive`, `--squash`, `--fixup=amend:`/`reword:`.
|
|
1286
|
+
const COMMIT_FLAGS = new Set(['-a', '--all', '-q', '--quiet', '-s', '--signoff', '--no-signoff', '-n', '--no-verify', '--verify', '--allow-empty', '--allow-empty-message', '--amend', '--no-edit', '-o', '--only', '-i', '--include', '-v', '--verbose', '--no-verbose', '--status', '--no-status', '--dry-run', '--short', '--porcelain', '--long', '--branch', '-z', '--null', '--reset-author', '--no-post-rewrite', '-S', '--gpg-sign', '--no-gpg-sign', '--untracked-files'])
|
|
1287
|
+
const COMMIT_VALUED = new Set(['-m', '--message', '-F', '--file', '-C', '--reuse-message', '--author', '--date', '--cleanup', '--trailer', '--fixup'])
|
|
1288
|
+
// The message options: with one of them (or `--amend --no-edit`, or a dry run) commit opens no editor.
|
|
1289
|
+
const COMMIT_MESSAGE = new Set(['-m', '--message', '-F', '--file', '-C', '--reuse-message', '--fixup'])
|
|
1290
|
+
// The short options of a bundle (`-am`, `-sm"msg"`): flags, then one that takes the rest of the word or the next word.
|
|
1291
|
+
const COMMIT_SHORT_FLAGS = 'aqsnoivz'
|
|
1292
|
+
const COMMIT_SHORT_VALUED = 'mFCSu'
|
|
1293
|
+
// `git add` options that only choose what is staged; `-p`, `-i`, `-e`, `--renormalize` and the rest ask.
|
|
1294
|
+
const ADD_FLAGS = new Set(['-A', '--all', '--no-all', '-u', '--update', '-f', '--force', '-v', '--verbose', '-n', '--dry-run', '-N', '--intent-to-add', '--ignore-errors', '--ignore-missing', '--ignore-removal', '--no-ignore-removal', '--sparse', '--refresh', '--chmod=+x', '--chmod=-x'])
|
|
1295
|
+
const ADD_SHORT = 'AufvnN'
|
|
1296
|
+
|
|
1297
|
+
/** Whether a `git commit` runs neither an editor nor an interactive patch read: every option allowed, a message given. */
|
|
1298
|
+
function plainCommit(rest: readonly string[]): boolean {
|
|
1299
|
+
const seen = new Set<string>()
|
|
1300
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1301
|
+
const word = rest[i] ?? ''
|
|
1302
|
+
if (word === '--') break
|
|
1303
|
+
if (!word.startsWith('-') || word === '-') continue
|
|
1304
|
+
const long = word.startsWith('--') ? word.split('=')[0] ?? word : null
|
|
1305
|
+
if (long !== null) {
|
|
1306
|
+
if (long === '--fixup' && /^--fixup=(?:amend|reword):/.test(word)) return false
|
|
1307
|
+
if (COMMIT_FLAGS.has(word) || ((long === '--untracked-files' || long === '--gpg-sign') && word.includes('='))) seen.add(long)
|
|
1308
|
+
else if (COMMIT_VALUED.has(long)) {
|
|
1309
|
+
seen.add(long)
|
|
1310
|
+
if (!word.includes('=')) i += 1
|
|
1311
|
+
} else return false
|
|
1312
|
+
continue
|
|
1313
|
+
}
|
|
1314
|
+
// A short option or bundle: `-a`, `-am`, `-mmsg`, `-S`, `-Skey`, `-uno`.
|
|
1315
|
+
for (let k = 1; k < word.length; k += 1) {
|
|
1316
|
+
const letter = word[k] ?? ''
|
|
1317
|
+
if (COMMIT_SHORT_FLAGS.includes(letter)) seen.add(`-${letter}`)
|
|
1318
|
+
else if (COMMIT_SHORT_VALUED.includes(letter)) {
|
|
1319
|
+
seen.add(`-${letter}`)
|
|
1320
|
+
// -S and -u take their value only in the same word; -m, -F, -C take the rest of it or the next word.
|
|
1321
|
+
if (k === word.length - 1 && 'mFC'.includes(letter)) i += 1
|
|
1322
|
+
break
|
|
1323
|
+
} else return false
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
const message = [...seen].some(option => COMMIT_MESSAGE.has(option))
|
|
1327
|
+
return message || (seen.has('--amend') && seen.has('--no-edit')) || ['--dry-run', '--short', '--porcelain'].some(option => seen.has(option))
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** Whether a `git add` only stages: every option in {@link ADD_FLAGS} or a short bundle of {@link ADD_SHORT}. */
|
|
1331
|
+
function plainAdd(rest: readonly string[]): boolean {
|
|
1332
|
+
for (const word of rest) {
|
|
1333
|
+
if (word === '--') break
|
|
1334
|
+
if (!word.startsWith('-') || word === '-') continue
|
|
1335
|
+
if (!ADD_FLAGS.has(word) && !(/^-[^-]+$/.test(word) && [...word.slice(1)].every(letter => ADD_SHORT.includes(letter)))) return false
|
|
1336
|
+
}
|
|
1337
|
+
return true
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// `--format=`/`--pretty=` placeholders that verify a signature (`%G?`, `%GG`, `%GS`, `%GK`, `%GF`, `%GP`, `%GT`): they run gpg.program.
|
|
1341
|
+
const SIGNATURE_FORMAT = /^--(?:format|pretty)=.*%G/
|
|
1342
|
+
|
|
1343
|
+
function gitStep(sub: string | undefined, rest: readonly string[]): string {
|
|
1344
|
+
if (sub === 'commit' && !plainCommit(rest)) return 'commit [options]'
|
|
1345
|
+
if (sub === 'add' && !plainAdd(rest)) return 'add [options]'
|
|
1346
|
+
if (sub === 'branch' && rest.length === 1 && rest[0] === '--show-current') return 'branch --show-current'
|
|
1347
|
+
if (sub === 'tag' && rest.length === 1 && (rest[0] === '-l' || rest[0] === '--list')) return 'tag --list'
|
|
1348
|
+
if (sub === 'checkout' && rest[0] === '--' && rest.length > 1) return 'checkout -- <paths>'
|
|
1349
|
+
if (sub === 'fetch' && rest.some(word => word.startsWith('-') || word.includes('::'))) return 'fetch [options]'
|
|
1350
|
+
const display = (word: string): boolean => !word.startsWith('-') || DISPLAY_OPTIONS.has(word) || DISPLAY_VALUED.test(word)
|
|
1351
|
+
// A signature check runs gpg.program: `log [signature]` is local, but makes the wide scopes' gpg.program count.
|
|
1352
|
+
const signature = (word: string): boolean => word === '--show-signature' || SIGNATURE_FORMAT.test(word)
|
|
1353
|
+
if ((sub === 'log' || sub === 'show') && rest.some(signature)) return rest.every(word => signature(word) || display(word)) ? `${sub} [signature]` : `${sub} [options]`
|
|
1354
|
+
if ((sub === 'log' || sub === 'show' || sub === 'diff') && !rest.every(display)) return `${sub} [options]`
|
|
1355
|
+
return sub ?? ''
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** For each segment, the separator that ends its and-or list ('' at the end of the line). */
|
|
1359
|
+
function listEnds(segs: readonly Seg[]): Sep[] {
|
|
1360
|
+
const ends: Sep[] = new Array<Sep>(segs.length)
|
|
1361
|
+
let end: Sep = ''
|
|
1362
|
+
for (let k = segs.length - 1; k >= 0; k -= 1) {
|
|
1363
|
+
const after = segs[k]?.after ?? ''
|
|
1364
|
+
if (after === '' || LIST_END.has(after)) end = after
|
|
1365
|
+
ends[k] = end
|
|
1366
|
+
}
|
|
1367
|
+
return ends
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Walks the segments of a line in order. The folder ({@link WalkState.dir}) follows a `cd` only where
|
|
1372
|
+
* its effect on what follows is certain: a plain `cd`, `pushd` or `eval` that is {@link Seg.sure}, not
|
|
1373
|
+
* a pipeline stage and not after `||`. One after `&&`, or in a list that runs in the background, holds
|
|
1374
|
+
* for the rest of its and-or list only (`a && cd x; git push` runs the push wherever `a` left it); an
|
|
1375
|
+
* `||` later in the list (`cd x || cd y`, `cd x || echo; git push`) means it may not have run. Every
|
|
1376
|
+
* other case makes the folder {@link UNKNOWN_DIR}.
|
|
1377
|
+
*/
|
|
1378
|
+
function walkSegments(text: string, inners: readonly Inner[], depth: number, state: WalkState, hits: Outward[]): void {
|
|
1379
|
+
const segs = segmentsOf(text)
|
|
1380
|
+
const ends = listEnds(segs)
|
|
1381
|
+
let moved = false // a folder move happened in this and-or list
|
|
1382
|
+
let movedForList = false // … and it holds only until the list ends
|
|
1383
|
+
const move = (seg: Seg, k: number, to: string | null): void => {
|
|
1384
|
+
if (to === null || !seg.sure || seg.before === '||' || PIPE.has(seg.before) || PIPE.has(seg.after)) state.dir = UNKNOWN_DIR
|
|
1385
|
+
else {
|
|
1386
|
+
state.dir = to
|
|
1387
|
+
if (seg.before === '&&' || ends[k] === '&') movedForList = true
|
|
1388
|
+
}
|
|
1389
|
+
moved = true
|
|
1390
|
+
}
|
|
1391
|
+
const walkSegment = (seg: Seg, k: number): void => {
|
|
1392
|
+
if (LIST_END.has(seg.before)) {
|
|
1393
|
+
if (movedForList) state.dir = UNKNOWN_DIR
|
|
1394
|
+
moved = false
|
|
1395
|
+
movedForList = false
|
|
1396
|
+
} else if (seg.before === '||' && moved) state.dir = UNKNOWN_DIR
|
|
1397
|
+
const segment = seg.text
|
|
1398
|
+
gitInSubstitution(segment, inners, depth, state)
|
|
1399
|
+
const { words, assigned } = readCommand(segment, true)
|
|
1400
|
+
const [first, ...args] = words
|
|
1401
|
+
for (const name of assigned) if (!HARMLESS_VAR.test(name)) state.gitEnv.add(name)
|
|
1402
|
+
if (first === undefined) return
|
|
1403
|
+
// The command as written, prefixes kept: a `cd` behind `then`, `command` or `VAR=x` is not plain.
|
|
1404
|
+
const written = argWords(segment, true)
|
|
1405
|
+
const inner = innerAt(first, inners)
|
|
1406
|
+
if (inner !== null) {
|
|
1407
|
+
const dir = state.dir
|
|
1408
|
+
walkOutward(inner.command, depth + 1, state, hits)
|
|
1409
|
+
// A child shell's `cd` ends with it; `eval` runs in this shell. Its git calls count either way.
|
|
1410
|
+
if (!inner.sameShell) state.dir = dir
|
|
1411
|
+
else if (state.dir !== dir) {
|
|
1412
|
+
const to = state.dir
|
|
1413
|
+
state.dir = dir
|
|
1414
|
+
move(seg, k, written[0] === first ? to : null)
|
|
1415
|
+
}
|
|
1416
|
+
return
|
|
1417
|
+
}
|
|
1418
|
+
const cmd = baseName(first)
|
|
1419
|
+
if (cmd === 'cd' || cmd === 'pushd' || cmd === 'popd') {
|
|
1420
|
+
// `cd`, `cd dir`, `pushd dir`; never `popd`, `cd -`, `pushd +1`, an option or a second word.
|
|
1421
|
+
const arg = args[0]
|
|
1422
|
+
const plain = written[0] === cmd && written.length === words.length && cmd !== 'popd' && args.length <= 1 && (cmd === 'cd' || arg !== undefined) && !/^[-+]/.test(arg ?? '')
|
|
1423
|
+
move(seg, k, plain ? joinDir(state.dir, arg) : null)
|
|
1424
|
+
return
|
|
1425
|
+
}
|
|
1426
|
+
if (DECLARERS.has(cmd)) {
|
|
1427
|
+
// `declare -n r=HOME` makes r a name for HOME: its value is a name being set too.
|
|
1428
|
+
const nameref = args.some(word => /^-[a-zA-Z]*n/.test(word))
|
|
1429
|
+
for (const word of args) {
|
|
1430
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?(?:\+?=(.*))?$/s.exec(word)
|
|
1431
|
+
for (const name of [m?.[1], nameref ? /^[A-Za-z_][A-Za-z0-9_]*$/.exec(m?.[2] ?? '')?.[0] : undefined]) {
|
|
1432
|
+
if (name !== undefined && !HARMLESS_VAR.test(name)) state.gitEnv.add(name)
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
} else if (NAME_SETTERS.has(cmd)) {
|
|
1436
|
+
// printf sets a name only with -v; its format and arguments are text.
|
|
1437
|
+
const named = cmd === 'printf' ? args.flatMap((word, j) => (word === '-v' ? [args[j + 1] ?? ''] : word.startsWith('-v') ? [word.slice(2)] : [])) : args.map(word => word.replace(/^-[A-Za-z]*[aA](?=[A-Za-z_])/, ''))
|
|
1438
|
+
for (const word of named) {
|
|
1439
|
+
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(word)?.[0]
|
|
1440
|
+
if (name !== undefined && !HARMLESS_VAR.test(name)) state.gitEnv.add(name)
|
|
1441
|
+
}
|
|
1442
|
+
} else if (SHELL_STATE.has(cmd)) state.gitEnv.add(cmd)
|
|
1443
|
+
const hit = classifyWords(cmd, args, state.dir)
|
|
1444
|
+
if (hit !== null) hits.push(hit.kind === 'push' ? withPushContext(hit, state) : hit)
|
|
1445
|
+
if (cmd === 'git') {
|
|
1446
|
+
const { sub, rest, dir, config } = readGit(args, state.dir)
|
|
1447
|
+
const step = gitStep(sub, rest)
|
|
1448
|
+
// `git -c diff.external=./x diff` runs a program of its choice: local only with the push's own config allowlist.
|
|
1449
|
+
state.gitBefore.push(config.every(entry => SAFE_CONFIG.test(entry)) ? step : `${step} [${config.join(' ')}]`)
|
|
1450
|
+
// Its hooks and its config are those of the folder it ran in (`cd`, then `-C`): compared with the push's in withPushContext.
|
|
1451
|
+
state.stepDirs.set(state.gitBefore.length - 1, dir)
|
|
1452
|
+
if (sub === 'commit' && signsCommit(rest)) state.signs = true
|
|
1453
|
+
} else if (cmd.startsWith('git-')) {
|
|
1454
|
+
// `git-push`, or a git builtin run by path (`$(git --exec-path)/git-switch main`): never local.
|
|
1455
|
+
state.gitBefore.push(cmd === 'git-push' ? 'push' : cmd)
|
|
1456
|
+
} else if (runsOwnCommand(segment)) state.otherBefore = true
|
|
1457
|
+
}
|
|
1458
|
+
segs.forEach((seg, k) => {
|
|
1459
|
+
walkSegment(seg, k)
|
|
1460
|
+
// Judged after the segment's own hit is recorded, so it counts for the pushes after it: an output
|
|
1461
|
+
// redirection on any segment (a git step, `cd`, `exec`, an assignment) may write .git/config or
|
|
1462
|
+
// .git/HEAD. Ceiling: one on the push itself (`git push … > .git/config`) is not judged — the shell
|
|
1463
|
+
// opens it before git starts, so it can only truncate the file, and git writes into it only after
|
|
1464
|
+
// it read its refs and config.
|
|
1465
|
+
if (writesOutput(seg.text)) state.otherBefore = true
|
|
1466
|
+
})
|
|
1467
|
+
if (movedForList) state.dir = UNKNOWN_DIR
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// A `$(…)` or backtick span holding no quote, parenthesis, backtick or backslash, so where it ends is certain.
|
|
1471
|
+
const PLAIN_SUBSTITUTION = /\$\([^()'"`\\]*\)|`[^`'"\\$]*`/g
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Whether a segment whose first word is not git runs a command of its own: read again with each plain
|
|
1475
|
+
* substitution as one word, so `h=$(git rev-parse HEAD)` runs none (its body was read by
|
|
1476
|
+
* {@link gitInSubstitution}). Any word left — a command, or a word split out of a substitution this
|
|
1477
|
+
* cannot bound — counts, and so does a git call the first read missed (`h=$(echo x) git switch main`).
|
|
1478
|
+
*/
|
|
1479
|
+
function runsOwnCommand(segment: string): boolean {
|
|
1480
|
+
return readCommand(segment.replace(PLAIN_SUBSTITUTION, '_'), true).words.length > 0
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Resolves a `cd` target to an absolute folder (`bash -c CD_SCRIPT so-hud <dir>` in the session folder; the target is an
|
|
1485
|
+
* argument, never source): prints it and exits 0, or exits non-zero. A relative target that does not start with `.`
|
|
1486
|
+
* exits 3 while `CDPATH` is set (#1573 input): the session's shell looks it up there first, so `cd sub` may land
|
|
1487
|
+
* elsewhere than the folder this would resolve — the push then asks. Ceiling: a zsh `cdpath` set only in the session
|
|
1488
|
+
* shell's own startup files is not in this environment.
|
|
1489
|
+
*/
|
|
1490
|
+
export const CD_SCRIPT = `d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; /*|.|..|./*|../*) ;; *) [ -z "\${CDPATH:-}" ] || exit 3;; esac; unset CDPATH; cd -- "$d" 2>/dev/null && pwd -P`
|
|
1491
|
+
|
|
1492
|
+
/** Whether a folder the walker could not follow ({@link UNKNOWN_DIR}, or one below it). */
|
|
1493
|
+
const unknownDir = (dir: string | null): boolean => dir !== null && (dir === UNKNOWN_DIR || dir.startsWith(`${UNKNOWN_DIR}/`))
|
|
1494
|
+
|
|
1495
|
+
/** A push hit with what earlier segments of the line changed under the measured facts. */
|
|
1496
|
+
function withPushContext(hit: Outward, state: WalkState): Outward {
|
|
1497
|
+
const gitConfig = [...(hit.gitConfig ?? []), ...state.gitEnv]
|
|
1498
|
+
// A git step elsewhere ran hooks and config the facts never measured: compared as written, so `-C .` asks too.
|
|
1499
|
+
// A step without a recorded folder (`git-<name>`, `$(…)` past the depth) is not local anyway.
|
|
1500
|
+
const sameDir = (i: number): boolean => !state.stepDirs.has(i) || (state.stepDirs.get(i) === hit.dir && !unknownDir(hit.dir))
|
|
1501
|
+
return {
|
|
1502
|
+
...hit,
|
|
1503
|
+
...(state.gitBefore.length > 0 ? { gitBefore: state.gitBefore.map((step, i) => (sameDir(i) ? step : `${step} [other folder]`)) } : {}),
|
|
1504
|
+
...(state.otherBefore ? { otherBefore: true as const } : {}),
|
|
1505
|
+
...(state.signs ? { signsBefore: true as const } : {}),
|
|
1506
|
+
...(gitConfig.length > 0 ? { gitConfig } : {}),
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// git's own options before the subcommand that point it at another repository or ref namespace.
|
|
1511
|
+
const GIT_REDIRECTS = ['--git-dir', '--work-tree', '--namespace']
|
|
1512
|
+
// git's global options that change nothing about where a push goes; every other one is reported as config.
|
|
1513
|
+
const GIT_GLOBAL_PLAIN = new Set(['--no-pager', '-P', '-p', '--paginate', '--no-optional-locks'])
|
|
1514
|
+
|
|
1515
|
+
/** git's global options read off: the subcommand, its words, the folder `-C` moved to, and the config the facts did not see. */
|
|
1516
|
+
function readGit(args: readonly string[], dir: string | null): { sub: string | undefined; rest: string[]; dir: string | null; config: string[] } {
|
|
1517
|
+
let gitDir = dir
|
|
1518
|
+
const config: string[] = []
|
|
1519
|
+
let i = 0
|
|
1520
|
+
while (i < args.length && (args[i] ?? '').startsWith('-')) {
|
|
1521
|
+
const arg = args[i] ?? ''
|
|
1522
|
+
const value = args[i + 1]
|
|
1523
|
+
if (value !== undefined && arg === '-C') {
|
|
1524
|
+
gitDir = joinDir(gitDir, value)
|
|
1525
|
+
i += 2
|
|
1526
|
+
} else if (value !== undefined && (arg === '-c' || arg === '--config-env')) {
|
|
1527
|
+
config.push(value)
|
|
1528
|
+
i += 2
|
|
1529
|
+
} else if (value !== undefined && GIT_REDIRECTS.includes(arg)) {
|
|
1530
|
+
config.push(`${arg}=${value}`)
|
|
1531
|
+
i += 2
|
|
1532
|
+
} else if (value !== undefined && arg === '--attr-source') {
|
|
1533
|
+
config.push(`${arg}=${value}`)
|
|
1534
|
+
i += 2
|
|
1535
|
+
} else {
|
|
1536
|
+
if (arg.startsWith('--config-env=')) config.push(arg.slice('--config-env='.length))
|
|
1537
|
+
else if (!GIT_GLOBAL_PLAIN.has(arg)) config.push(arg)
|
|
1538
|
+
i += 1
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return { sub: args[i], rest: args.slice(i + 1), dir: gitDir, config }
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Without -X, gh and glab send POST as soon as a field or --input is given.
|
|
1545
|
+
const API_FIELDS = /^(?:-f|-F|--field|--raw-field|--input)(?:=|$)|^-[fF]./
|
|
1546
|
+
const GRAPHQL_MERGE = /\b(?:mergePullRequest|enablePullRequestAutoMerge|mergeBranch|mergeRequestAccept)\b/
|
|
1547
|
+
const API_REF_MAIN = /(?:^|\/)git\/refs\/heads\/(?:main|master)\/?(?:\?.*)?$/
|
|
1548
|
+
|
|
1549
|
+
const decodePath = (word: string): string => {
|
|
1550
|
+
try {
|
|
1551
|
+
return decodeURIComponent(word)
|
|
1552
|
+
} catch {
|
|
1553
|
+
return word
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* What a forge API call that writes does, or null: a write to `git/refs/heads/main|master` (or a
|
|
1559
|
+
* `git/refs` create naming it), and any write carrying the field `branch=main|master` (a contents
|
|
1560
|
+
* or commits write onto the default branch), is a push; a REST `…/merge` or `…/merges`, a GraphQL
|
|
1561
|
+
* merge mutation, and a GraphQL body this cannot read (`-F query=@file`, `--input`) are merges.
|
|
1562
|
+
*/
|
|
1563
|
+
function apiAction(apiArgs: readonly string[]): { kind: 'push' | 'merge'; path: string } | null {
|
|
1564
|
+
let method: string | undefined
|
|
1565
|
+
const fields: string[] = []
|
|
1566
|
+
// Indices of the words that are an option's value, so the endpoint is the first word that is none.
|
|
1567
|
+
const values = new Set<number>()
|
|
1568
|
+
for (let i = 0; i < apiArgs.length; i += 1) {
|
|
1569
|
+
const arg = apiArgs[i] ?? ''
|
|
1570
|
+
if (arg === '-X' || arg === '--method') {
|
|
1571
|
+
method = apiArgs[i + 1]
|
|
1572
|
+
values.add(i + 1)
|
|
1573
|
+
} else if (arg.startsWith('--method=')) method = arg.slice('--method='.length)
|
|
1574
|
+
else if (/^-X./.test(arg)) method = arg.slice(2)
|
|
1575
|
+
else if (arg === '-f' || arg === '-F' || arg === '--field' || arg === '--raw-field') {
|
|
1576
|
+
fields.push(apiArgs[i + 1] ?? '')
|
|
1577
|
+
values.add(i + 1)
|
|
1578
|
+
} else if (/^-[fF]./.test(arg)) fields.push(arg.slice(2))
|
|
1579
|
+
else if (/^--(?:raw-)?field=/.test(arg)) fields.push(arg.slice(arg.indexOf('=') + 1))
|
|
1580
|
+
}
|
|
1581
|
+
const writes = method !== undefined ? /^(?:PUT|POST|PATCH|DELETE)$/i.test(method) : apiArgs.some(a => API_FIELDS.test(a))
|
|
1582
|
+
if (!writes) return null
|
|
1583
|
+
const words = apiArgs.filter(a => !a.startsWith('-')).map(decodePath)
|
|
1584
|
+
const ref = words.find(a => API_REF_MAIN.test(a))
|
|
1585
|
+
if (ref !== undefined) return { kind: 'push', path: ref }
|
|
1586
|
+
const refs = words.find(a => /(?:^|\/)git\/refs\/?$/.test(a))
|
|
1587
|
+
if (refs !== undefined && fields.some(v => /^ref=refs\/heads\/(?:main|master)$/.test(v))) return { kind: 'push', path: refs }
|
|
1588
|
+
if (fields.some(v => /^branch=(?:refs\/heads\/)?(?:main|master)$/.test(v))) {
|
|
1589
|
+
return { kind: 'push', path: apiArgs.find((a, i) => !a.startsWith('-') && !values.has(i)) ?? 'api' }
|
|
1590
|
+
}
|
|
1591
|
+
const merge = words.find(a => /\/merges?\/?(?:\?.*)?$/.test(a))
|
|
1592
|
+
if (merge !== undefined) return { kind: 'merge', path: merge }
|
|
1593
|
+
const unreadBody = apiArgs.some(a => a === '--input' || a.startsWith('--input=')) || fields.some(v => v.startsWith('query=@'))
|
|
1594
|
+
return words.includes('graphql') && (unreadBody || apiArgs.some(a => GRAPHQL_MERGE.test(a))) ? { kind: 'merge', path: 'graphql' } : null
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// The options of git push that take a separate value.
|
|
1598
|
+
const PUSH_VALUED = new Set(['-o', '--push-option', '--receive-pack', '--exec', '--repo'])
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Whether the last dry-run switch before `--` turns dry-run on: `--dry-run`, `--dry-run=true` and,
|
|
1602
|
+
* when `shortN`, a lone `-n` turn it on; `--no-dry-run` and any other `--dry-run=` turn it off. A
|
|
1603
|
+
* value of an option in `valued` (`git push -o -n`) is no switch.
|
|
1604
|
+
*/
|
|
1605
|
+
function isDryRun(args: readonly string[], valued: ReadonlySet<string>, shortN: boolean): boolean {
|
|
1606
|
+
let dry = false
|
|
1607
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1608
|
+
const arg = args[i] ?? ''
|
|
1609
|
+
if (arg === '--') break
|
|
1610
|
+
if (valued.has(arg)) i += 1
|
|
1611
|
+
else if (arg === '--dry-run' || arg === '--dry-run=true' || (shortN && arg === '-n')) dry = true
|
|
1612
|
+
else if (arg === '--no-dry-run' || arg.startsWith('--dry-run=')) dry = false
|
|
1613
|
+
}
|
|
1614
|
+
return dry
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// npm/pnpm/yarn options before the subcommand that take a separate value (`-w pkg`, `--filter x`).
|
|
1618
|
+
const PM_VALUED = new Set(['-w', '--workspace', '--prefix', '--registry', '--tag', '--otp', '--access', '--userconfig', '--cache', '--filter', '-F', '-C', '--dir', '--cwd', '--loglevel', '--reporter', '--include', '--exclude', '--from', '--since', '-j', '--jobs'])
|
|
1619
|
+
// Subcommands that only name `publish` as a word (`npm help publish`) or a package (`npm i publish`).
|
|
1620
|
+
const PM_NOT_PUBLISHING = new Set(['help', 'view', 'info', 'show', 'v', 'search', 's', 'docs', 'home', 'repo', 'bugs', 'why', 'explain', 'ls', 'list', 'outdated', 'audit', 'install', 'i', 'add', 'remove', 'rm', 'uninstall', 'un'])
|
|
1621
|
+
|
|
1622
|
+
/** Whether npm/pnpm/yarn publishes: `publish` among the positionals before `--`, so `-w pkg`, `--filter x` or `yarn workspace x` in front cannot hide it. */
|
|
1623
|
+
function pmPublishes(args: readonly string[]): boolean {
|
|
1624
|
+
const end = args.indexOf('--')
|
|
1625
|
+
const pos = positionals(end === -1 ? args : args.slice(0, end), PM_VALUED)
|
|
1626
|
+
return pos.includes('publish') && !PM_NOT_PUBLISHING.has(pos[0] ?? '')
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function classifyWords(cmd: string, args: string[], dir: string | null): Outward | null {
|
|
1630
|
+
const [sub, action] = args
|
|
1631
|
+
if (cmd === 'git' || cmd === 'git-push') {
|
|
1632
|
+
const git = cmd === 'git' ? readGit(args, dir) : { sub: 'push', rest: args, dir, config: [] }
|
|
1633
|
+
if (git.sub !== 'push') return null
|
|
1634
|
+
if (isDryRun(git.rest, PUSH_VALUED, true)) return null
|
|
1635
|
+
return { kind: 'push', label: 'git push', args: git.rest, dir: git.dir, ...(git.config.length > 0 ? { gitConfig: git.config } : {}) }
|
|
1636
|
+
}
|
|
1637
|
+
// Wrappers like glab-<name> speak the same API as glab.
|
|
1638
|
+
if (cmd === 'glab' || cmd === 'gh' || /^glab-[a-z0-9-]+$/.test(cmd)) {
|
|
1639
|
+
const forge = cmd === 'gh' ? 'gh' : 'glab'
|
|
1640
|
+
// `glab mr accept` is glab's alias of `glab mr merge`.
|
|
1641
|
+
if ((sub === 'mr' || sub === 'pr') && (action === 'merge' || action === 'accept')) {
|
|
1642
|
+
return { kind: 'merge', label: `${cmd} ${sub} ${action}`, args: args.slice(2), dir, forge }
|
|
1643
|
+
}
|
|
1644
|
+
if (sub === 'release' && action === 'create') {
|
|
1645
|
+
return { kind: 'release', label: `${cmd} release create`, args: args.slice(2), dir, forge }
|
|
1646
|
+
}
|
|
1647
|
+
const api = sub === 'api' ? apiAction(args.slice(1)) : null
|
|
1648
|
+
if (api !== null) {
|
|
1649
|
+
return { kind: api.kind, label: `${cmd} api ${api.path}`, args: args.slice(1), dir, forge, ...(api.kind === 'push' ? { opaque: true as const } : {}) }
|
|
1650
|
+
}
|
|
1651
|
+
return null
|
|
1652
|
+
}
|
|
1653
|
+
const joined = [cmd, ...args].join(' ')
|
|
1654
|
+
if (isDryRun(args, PM_VALUED, false)) return null
|
|
1655
|
+
if (((cmd === 'npm' || cmd === 'pnpm' || cmd === 'yarn') && pmPublishes(args)) || /^changeset publish\b/.test(joined) || /^cargo publish\b/.test(joined)) {
|
|
1656
|
+
return { kind: 'publish', label: [cmd, ...args.slice(0, args.indexOf('publish') + 1)].join(' '), args, dir, tool: cmd }
|
|
1657
|
+
}
|
|
1658
|
+
if (cmd === 'vercel' && (args.includes('--prod') || args.includes('--production') || sub === 'promote' || sub === 'rollback')) {
|
|
1659
|
+
return { kind: 'deploy', label: `vercel ${args.filter(a => !a.startsWith('--token')).join(' ')}`.trim(), args, dir, tool: 'vercel' }
|
|
1660
|
+
}
|
|
1661
|
+
if (cmd === 'supabase' && ((sub === 'db' && (action === 'push' || (action === 'reset' && args.includes('--linked')))) || (sub === 'migration' && action === 'up' && args.includes('--linked')))) {
|
|
1662
|
+
return { kind: 'deploy', label: `supabase ${sub} ${action}`, args, dir, tool: 'supabase' }
|
|
1663
|
+
}
|
|
1664
|
+
if ((cmd === 'fly' || cmd === 'flyctl') && sub === 'deploy') {
|
|
1665
|
+
return { kind: 'deploy', label: 'fly deploy', args, dir, tool: 'fly' }
|
|
1666
|
+
}
|
|
1667
|
+
if (cmd === 'railway' && sub === 'up') {
|
|
1668
|
+
return { kind: 'deploy', label: 'railway up', args, dir, tool: 'railway' }
|
|
1669
|
+
}
|
|
1670
|
+
return null
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// ---- Which pushes may skip the confirmation ----------------------------------
|
|
1674
|
+
|
|
1675
|
+
const PROTECTED_BRANCHES = ['main', 'master']
|
|
1676
|
+
// The only options a push may carry and skip the confirmation, matched as exact words.
|
|
1677
|
+
const PUSH_FLAGS = new Set(['-u', '--set-upstream', '-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress'])
|
|
1678
|
+
// The only git steps that may run earlier on the line (see {@link gitStep}): none moves the branch, its upstream or the config.
|
|
1679
|
+
const LOCAL_GIT = new Set(['add', 'commit', 'status', 'log', 'diff', 'show', 'log [signature]', 'show [signature]', 'rev-parse', 'fetch', 'ls-files', 'branch --show-current', 'tag --list', 'checkout -- <paths>'])
|
|
1680
|
+
// The steps of LOCAL_GIT that can fire a hook (pre-/post-commit, reference-transaction,
|
|
1681
|
+
// post-index-change, post-checkout), which runs any program: local only when no hook can run ({@link PushFacts.hooks}).
|
|
1682
|
+
const HOOK_GIT = new Set(['add', 'commit', 'status', 'diff', 'fetch', 'checkout -- <paths>'])
|
|
1683
|
+
// The only `-c` / `--config-env` keys a push may carry and skip the confirmation.
|
|
1684
|
+
const SAFE_CONFIG = /^(?:user\.(?:name|email)|core\.quotepath|color\.[^=]+)(?:=|$)/i
|
|
1685
|
+
const PLAIN_REMOTE = /^[A-Za-z0-9._-]+$/
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* What the wiring measured with `git` in the push's folder (`hit.dir`), each read independently.
|
|
1689
|
+
* Any read that failed, printed nothing, or was not run is `null` / `false`: the push then needs
|
|
1690
|
+
* confirmation (fail-closed).
|
|
1691
|
+
*/
|
|
1692
|
+
export type PushFacts = {
|
|
1693
|
+
/** `git rev-parse --abbrev-ref HEAD` (`HEAD` when detached). */
|
|
1694
|
+
current: string | null
|
|
1695
|
+
/** `git rev-parse --abbrev-ref @{u}`, e.g. `origin/feat/x`. */
|
|
1696
|
+
upstream: string | null
|
|
1697
|
+
/** `git config branch.<current>.remote`; null when unset, so {@link pushRemote} falls back to `origin`. */
|
|
1698
|
+
remote: string | null
|
|
1699
|
+
/** `git remote get-url <pushRemote(hit, facts.remote)>` exited 0 with a non-empty URL. */
|
|
1700
|
+
remoteUrlKnown: boolean
|
|
1701
|
+
/**
|
|
1702
|
+
* Each destination from {@link pushDestinations} for which `git rev-parse --verify --quiet refs/tags/<dst>`
|
|
1703
|
+
* exited 0. null when the probes were not run.
|
|
1704
|
+
*/
|
|
1705
|
+
tagRefs: readonly string[] | null
|
|
1706
|
+
/**
|
|
1707
|
+
* The push configuration a previous call may have set (#1569). Optional only so a caller from before
|
|
1708
|
+
* it still type-checks: absent (`undefined`) or `null` (any of its reads failed) asks, never runs.
|
|
1709
|
+
*/
|
|
1710
|
+
pushConfig?: PushConfig | null
|
|
1711
|
+
/**
|
|
1712
|
+
* Whether a hook the earlier git steps can fire exists in the push's folder (#1574): only the hook
|
|
1713
|
+
* names those steps fire are looked up in the hooks directory (`git rev-parse --git-path hooks`), a
|
|
1714
|
+
* verified husky v9 stub counts only when its `.husky/<name>` exists, and a config hook only for those
|
|
1715
|
+
* events; `false` only when measured absent; `null` when a read failed or the layout is unknown. Absent or `null` asks like
|
|
1716
|
+
* `true` — for a push behind a step in {@link HOOK_GIT} (MR !133: a post-commit hook of
|
|
1717
|
+
* `git commit && git push origin HEAD` may switch HEAD or write the push configuration). A hook step
|
|
1718
|
+
* in another folder asks whatever this holds ({@link Outward.gitBefore}).
|
|
1719
|
+
*/
|
|
1720
|
+
hooks?: boolean | null
|
|
1721
|
+
/**
|
|
1722
|
+
* Every configured key with the scope git read it from ({@link REPO_CONFIG_READ}, parsed by
|
|
1723
|
+
* {@link repoConfigEntries}). A key such as `core.fsmonitor`, `diff.external`, `filter.<x>.clean` or
|
|
1724
|
+
* `core.sshCommand` makes a local step (`status`, `diff`, `add`, `fetch`) run a program, which may move
|
|
1725
|
+
* HEAD or write the push configuration after the facts were measured (#1573). Absent or `null` (the read
|
|
1726
|
+
* failed, or its output did not parse) asks for every push behind a git step, like a key outside
|
|
1727
|
+
* {@link repoConfigStandard}.
|
|
1728
|
+
*/
|
|
1729
|
+
repoConfig?: RepoConfig | null
|
|
1730
|
+
/**
|
|
1731
|
+
* Whether the index of the push's folder holds a gitlink (mode 160000: a submodule, or an embedded
|
|
1732
|
+
* repository added without `.gitmodules`), counted by {@link GITLINKS_SCRIPT}. `git status`, `git diff`
|
|
1733
|
+
* and `git fetch` step into such a repository and run its own `core.fsmonitor`, filters and transport,
|
|
1734
|
+
* which {@link repoConfig} never read (#1573 review; measured with git 2.56.0: an embedded `sub/` with
|
|
1735
|
+
* its own `core.fsmonitor` ran it four times during the outer repository's `git status` and `git diff`).
|
|
1736
|
+
* `false` only when counted zero; absent or `null` (the count failed) asks like `true` for every push
|
|
1737
|
+
* behind a git step. This also covers `submodule.recurse`, which only acts where gitlinks exist.
|
|
1738
|
+
*/
|
|
1739
|
+
gitlinks?: boolean | null
|
|
1740
|
+
/**
|
|
1741
|
+
* `git rev-parse --symbolic-full-name <src>` for each source of {@link pushSources}, in order (#1570); null
|
|
1742
|
+
* entry when the read failed or printed nothing (an ambiguous name exits 0 with nothing on stdout, git 2.56.0).
|
|
1743
|
+
* A push naming its branch runs only when each is exactly `refs/heads/<name>`: a symbolic ref `feat/x` →
|
|
1744
|
+
* `refs/heads/main` is pushed as `feat/x -> main`, and a `refs/feat/x` without that branch is pushed to `refs/feat/x`
|
|
1745
|
+
* (beside the branch the name is ambiguous and git refuses the push; both measured, git 2.56.0). Absent
|
|
1746
|
+
* or `null` asks for every push that names a source; `HEAD` and a bare push name none ({@link pushSources}).
|
|
1747
|
+
*/
|
|
1748
|
+
sourceRefs?: readonly (string | null)[] | null
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Counts the gitlinks of the index in the folder it runs in (`bash -c <script> so-hud-gitlinks`): prints
|
|
1753
|
+
* the count and exits 0, or exits non-zero when `git ls-files` or `grep` failed (pipefail; grep's exit 1
|
|
1754
|
+
* for no match is turned into 0). Paths with unusual characters are quoted by git, so each entry is one
|
|
1755
|
+
* line starting with its mode. Reads the whole index but prints one number, so no output limit is hit.
|
|
1756
|
+
*/
|
|
1757
|
+
export const GITLINKS_SCRIPT = 'set -o pipefail; git ls-files --stage | { LC_ALL=C grep -c "^160000 " || [ $? -eq 1 ]; }'
|
|
1758
|
+
|
|
1759
|
+
/** {@link PushFacts.gitlinks} from a {@link GITLINKS_SCRIPT} run: null unless it exited 0 and printed one count. */
|
|
1760
|
+
export function gitlinksFromRun(exitCode: number, stdout: string): boolean | null {
|
|
1761
|
+
const count = /^(\d+)\n?$/.exec(stdout)?.[1]
|
|
1762
|
+
return exitCode === 0 && count !== undefined ? Number(count) > 0 : null
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* `[scope, key, value]` per configured entry, as `git config --show-scope` prints them (`local`,
|
|
1767
|
+
* `core.fsmonitor`, `./x`); the value is null for a key written without `=`.
|
|
1768
|
+
*/
|
|
1769
|
+
export type RepoConfig = readonly (readonly [scope: string, key: string, value: string | null])[]
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* The read behind {@link PushFacts.repoConfig}, run as `git <words>` in the push's folder: every entry of
|
|
1773
|
+
* every scope, `<scope>\0<key>\n<value>\0` each (`<scope>\0<key>\0` without `=`), entries of an included
|
|
1774
|
+
* file under the scope of the file including it (measured, git 2.56.0). One process per hook call, and
|
|
1775
|
+
* only when the push would otherwise run. The values stay inside the hook; nothing of them is shown.
|
|
1776
|
+
*/
|
|
1777
|
+
export const REPO_CONFIG_READ: readonly string[] = ['config', '--null', '--show-scope', '--list']
|
|
1778
|
+
|
|
1779
|
+
/** The entries of a {@link REPO_CONFIG_READ} output (exit 0); null when it does not parse. Nothing printed is no key. */
|
|
1780
|
+
export function repoConfigEntries(stdout: string): RepoConfig | null {
|
|
1781
|
+
if (stdout === '') return []
|
|
1782
|
+
if (!stdout.endsWith('\0')) return null
|
|
1783
|
+
const fields = stdout.slice(0, -1).split('\0')
|
|
1784
|
+
if (fields.length % 2 !== 0) return null
|
|
1785
|
+
const entries: (readonly [string, string, string | null])[] = []
|
|
1786
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
1787
|
+
const entry = fields[i + 1] ?? ''
|
|
1788
|
+
const cut = entry.indexOf('\n')
|
|
1789
|
+
entries.push(cut === -1 ? [fields[i] ?? '', entry, null] : [fields[i] ?? '', entry.slice(0, cut), entry.slice(cut + 1)])
|
|
1790
|
+
}
|
|
1791
|
+
return entries
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* The repository keys that run no program — an allowlist (#1573), measured 2026-10-16 as every key set in
|
|
1796
|
+
* the 27 local configs of one owner's repos (`git config --local --name-only --list`), plus the push keys
|
|
1797
|
+
* {@link pushConfigStandard} judges. git prints section and key in lower case and the subsection as
|
|
1798
|
+
* written. `core.hookspath` is judged by {@link PushFacts.hooks}. `include.path` and `includeIf` ask:
|
|
1799
|
+
* nothing more is needed, as the included keys are listed too. Program keys outside: `core.fsmonitor`,
|
|
1800
|
+
* `core.sshcommand`, `core.pager`, `core.editor`, `diff.external`, `diff.<x>.textconv`, `filter.<x>.*`,
|
|
1801
|
+
* `merge.<x>.driver`, `gpg.program`, `credential.helper`, `remote.<x>.uploadpack`, `protocol.*`,
|
|
1802
|
+
* `submodule.<x>.update`, `alias.*`, `url.*` — and any key git adds later. A remote URL is judged by its
|
|
1803
|
+
* value too ({@link TRANSPORT_URL}).
|
|
1804
|
+
*/
|
|
1805
|
+
const PLAIN_REPO_KEY = /^(?:core\.(?:repositoryformatversion|filemode|bare|logallrefupdates|ignorecase|precomposeunicode|hookspath|sparsecheckout|sparsecheckoutcone)|index\.sparse|extensions\.worktreeconfig|user\.(?:name|email)|gc\.auto|maintenance\.auto|submodule\.recurse|push\.[a-z]+|remote\.pushdefault|branch\..+\.(?:remote|merge|pushremote|vscode-merge-base)|remote\..+\.(?:url|pushurl|fetch|push|mirror|glab-resolved[a-z-]*))$/
|
|
1806
|
+
|
|
1807
|
+
// A remote URL in git's `<transport>::<address>` form: `git fetch` starts `git-remote-<transport>` from
|
|
1808
|
+
// PATH (`ext::` is refused by default, every other transport allowed) — the same `::` word gitStep refuses
|
|
1809
|
+
// on `fetch`. A `url.<base>.insteadOf` whose base has one rewrites a plain remote into such a URL.
|
|
1810
|
+
const TRANSPORT_URL = /^remote\..+\.(?:url|pushurl)$/
|
|
1811
|
+
const TRANSPORT_REWRITE = /^url\..*::.*\.(?:insteadof|pushinsteadof)$/
|
|
1812
|
+
|
|
1813
|
+
// Keys of the global and system scopes that make a local step run a program whatever their value (#1573
|
|
1814
|
+
// review): `diff.external` and textconv for `diff`/`log -p`, `core.sshCommand`, `core.askPass`,
|
|
1815
|
+
// `core.gitProxy`, an upload-pack override and `uploadpack.packObjectsHook` (read only from these scopes,
|
|
1816
|
+
// run when fetching from a local path) for `fetch`, and `remote.<r>.vcs`, which routes a remote through
|
|
1817
|
+
// `git-remote-<vcs>` from PATH.
|
|
1818
|
+
const PROGRAM_KEY = /^(?:diff\.external|core\.(?:sshcommand|askpass|gitproxy)|diff\..+\.(?:textconv|command)|remote\..+\.(?:uploadpack|receivepack|vcs)|uploadpack\.packobjectshook)$/
|
|
1819
|
+
// git's spellings of a boolean; `core.fsmonitor` set to one uses git's own daemon or none, never a program.
|
|
1820
|
+
const GIT_BOOL = /^(?:true|yes|on|1|false|no|off|0|)$/i
|
|
1821
|
+
// The values git-lfs writes with `git lfs install` — the only filter programs the wide scopes may name.
|
|
1822
|
+
const LFS_FILTERS = new Set(['git-lfs clean -- %f', 'git-lfs smudge -- %f', 'git-lfs filter-process'])
|
|
1823
|
+
// The credential helpers git and the forge CLIs install (`gh`/`glab auth setup-git`), and `''` (resets the
|
|
1824
|
+
// list). A helper anywhere else, or `!` with any other command, asks. Measured on one host 2026-10-16:
|
|
1825
|
+
// osxkeychain, `!/opt/homebrew/bin/gh auth git-credential`, `!glab auth git-credential`, `''`.
|
|
1826
|
+
const KNOWN_HELPER = /^(?:|osxkeychain|manager|manager-core|wincred|libsecret|store|cache(?: --timeout=\d+)?|!(?:(?:\/opt\/homebrew\/bin|\/usr\/local\/bin|\/usr\/bin)\/)?(?:gh|glab) auth git-credential)$/
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* Whether an entry of the `global` or `system` scope makes a local step run a program — a denylist,
|
|
1830
|
+
* because these scopes hold the host's own helpers. `gpg.program` counts only when a `commit` runs on the
|
|
1831
|
+
* line and signs: `commit.gpgSign` on there (last value wins) or an explicit `-S` ({@link Outward.signsBefore}) — or when
|
|
1832
|
+
* a `log`/`show` step verifies signatures (`--show-signature`, a `%G` placeholder, or `log.showSignature` on: `verifies`);
|
|
1833
|
+
* `log.showSignature` itself counts behind any `log`/`show` step (`logs`). Not judged: `core.pager` (no step
|
|
1834
|
+
* here pages: its output is no terminal), `core.editor` (a commit message given with `-m` opens none; the
|
|
1835
|
+
* host's editor is set on most hosts), `include.path`/`includeIf` (the included entries are listed and
|
|
1836
|
+
* judged themselves), `core.hooksPath`/`hook.*` ({@link PushFacts.hooks}).
|
|
1837
|
+
*/
|
|
1838
|
+
function wideProgram(key: string, value: string | null, signing: boolean, commits: boolean, verifies: boolean, logs: boolean): boolean {
|
|
1839
|
+
const k = key.toLowerCase()
|
|
1840
|
+
// `log.showSignature` makes a plain `git log`/`git show` verify signatures, which runs gpg (#1573 input): a denylist entry.
|
|
1841
|
+
if (k === 'log.showsignature') return logs && !/^(?:false|no|off|0)$/i.test(value ?? 'true')
|
|
1842
|
+
if (PROGRAM_KEY.test(k) || TRANSPORT_REWRITE.test(k)) return true
|
|
1843
|
+
if (TRANSPORT_URL.test(k)) return (value ?? '').includes('::')
|
|
1844
|
+
if (k === 'core.fsmonitor') return !GIT_BOOL.test(value ?? '')
|
|
1845
|
+
if (/^filter\..+\.(?:clean|smudge|process)$/.test(k)) return !LFS_FILTERS.has(value ?? '')
|
|
1846
|
+
if (/^credential\.(?:.+\.)?helper$/.test(k)) return !KNOWN_HELPER.test(value ?? '')
|
|
1847
|
+
if (/^gpg\.(?:.+\.)?program$/.test(k)) return (signing && commits) || verifies
|
|
1848
|
+
return false
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/**
|
|
1852
|
+
* Whether no configured entry can make a local git step (`steps`, {@link Outward.gitBefore}) run a program:
|
|
1853
|
+
* every entry of the repository's own scopes (`local`, `worktree`, `command`, and any other git reports,
|
|
1854
|
+
* includes followed) is in {@link PLAIN_REPO_KEY}, with no remote URL in the `<transport>::` form; and no
|
|
1855
|
+
* entry of `global` or `system` is a program per {@link wideProgram}.
|
|
1856
|
+
* Ceiling: the wide scopes are judged by a denylist, so a program key git adds later passes there; the
|
|
1857
|
+
* repository's own scopes stay an allowlist.
|
|
1858
|
+
*/
|
|
1859
|
+
function repoConfigStandard(config: RepoConfig | null | undefined, steps: readonly string[], signs: boolean): boolean {
|
|
1860
|
+
if (!Array.isArray(config) || !config.every(entry => Array.isArray(entry))) return false
|
|
1861
|
+
const wide = (scope: unknown): boolean => scope === 'global' || scope === 'system'
|
|
1862
|
+
const signing = config.reduce((on, [scope, key, value]) => (wide(scope) && String(key).toLowerCase() === 'commit.gpgsign' ? /^(?:true|yes|on|1)$/i.test(value ?? 'true') : on), false)
|
|
1863
|
+
const commits = steps.includes('commit')
|
|
1864
|
+
const verifies = steps.some(step => step.endsWith(' [signature]'))
|
|
1865
|
+
const logs = steps.some(step => /^(?:log|show)(?: \[signature\])?$/.test(step))
|
|
1866
|
+
// A log or show that verifies signatures, or any log or show where the wide scopes turn log.showSignature on, runs gpg.program.
|
|
1867
|
+
const showsSignature = logs && config.some(([scope, key, value]) => wide(scope) && String(key).toLowerCase() === 'log.showsignature' && !/^(?:false|no|off|0)$/i.test(value ?? 'true'))
|
|
1868
|
+
return config.every(([scope, key, value]) => {
|
|
1869
|
+
if (wide(scope)) return !wideProgram(String(key), value, signing || signs, commits, verifies || showsSignature, logs)
|
|
1870
|
+
return PLAIN_REPO_KEY.test(String(key)) && !(TRANSPORT_URL.test(String(key)) && (value ?? '').includes('::'))
|
|
1871
|
+
})
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Push configuration read with `git config --null` in the push's folder, each value as measured. Exit 1
|
|
1876
|
+
* with no output means unset (`null`, or `[]`); any other failure (exit code, timeout, output that does
|
|
1877
|
+
* not parse) makes the whole {@link PushFacts.pushConfig} `null`. `<target>` is
|
|
1878
|
+
* `pushRemote(hit, facts.remote)`, `<current>` is `facts.current`. `--null` keeps a value that is empty
|
|
1879
|
+
* or holds a newline one entry, so a set-but-empty `remote.<target>.push` never reads as unset.
|
|
1880
|
+
*/
|
|
1881
|
+
export type PushConfig = {
|
|
1882
|
+
/**
|
|
1883
|
+
* `git config --null --get-regexp '^push\.'`: every `push.*` entry as `[key, value]`, the key as git
|
|
1884
|
+
* prints it (`push.default`), the value null for a key written without `=`. `[]` when none is set.
|
|
1885
|
+
*/
|
|
1886
|
+
push: readonly (readonly [key: string, value: string | null])[]
|
|
1887
|
+
/** `git config --get-all remote.<target>.push`: every value (several are possible). `[]` when unset. */
|
|
1888
|
+
remotePush: readonly string[]
|
|
1889
|
+
/** `git config --type=bool --get remote.<target>.mirror`: `'true'`, `'false'`, or null when unset. */
|
|
1890
|
+
remoteMirror: string | null
|
|
1891
|
+
/** `git config --get remote.pushDefault`, or null when unset. */
|
|
1892
|
+
remotePushDefault: string | null
|
|
1893
|
+
/** `git config --get branch.<current>.pushRemote`, or null when unset (and when detached: not read). */
|
|
1894
|
+
branchPushRemote: string | null
|
|
1895
|
+
/** `git config --type=bool --get submodule.recurse`: `'true'`, `'false'`, or null when unset. */
|
|
1896
|
+
submoduleRecurse: string | null
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// The push.* keys that change nothing about where a plain push goes: autoSetupRemote acts only without
|
|
1900
|
+
// an upstream, and a bare push without one already asks ({@link pushDestinations} is null).
|
|
1901
|
+
const SAFE_PUSH_KEYS = new Set(['push.autosetupremote'])
|
|
1902
|
+
|
|
1903
|
+
const measuredValue = (value: unknown): value is string | null => value === null || typeof value === 'string'
|
|
1904
|
+
|
|
1905
|
+
/**
|
|
1906
|
+
* Whether the measured push configuration is the standard one for this push — an allowlist, and every
|
|
1907
|
+
* read must have happened (`undefined` anywhere asks):
|
|
1908
|
+
* - `remote.<target>.push` is unset: it maps even a named branch (`feat/x` → `refs/heads/main`).
|
|
1909
|
+
* - `remote.<target>.mirror` and `submodule.recurse` are unset or false: either pushes more than named.
|
|
1910
|
+
* - With no remote named, the remote git picks (`branch.<b>.pushRemote`, then `remote.pushDefault`, then
|
|
1911
|
+
* the measured one) is the measured target; a named remote overrides both, as in git.
|
|
1912
|
+
* - Every `push.*` key is `push.autosetupremote` or `push.default`. `push.default` must be `simple` —
|
|
1913
|
+
* git's default, which pushes the current branch only to its same-named upstream and refuses
|
|
1914
|
+
* otherwise, the model {@link pushDestinations} implements — or, when the push names its branch,
|
|
1915
|
+
* `current`. git reads push.default for a named branch too: under `upstream`/`tracking` a single
|
|
1916
|
+
* named branch goes to its upstream (measured, git 2.56.0: `git push -u origin feat/y` with feat/y
|
|
1917
|
+
* tracking origin/main printed `feat/y -> main`, #1569 H1). So `upstream`, `tracking`, `matching`
|
|
1918
|
+
* (every same-named branch, main included), `nothing` and any value git may add later ask, named or
|
|
1919
|
+
* not, as does any other `push.*` key (`pushOption` can merge on GitLab, `followTags` pushes tags,
|
|
1920
|
+
* `recurseSubmodules` pushes other repositories).
|
|
1921
|
+
*/
|
|
1922
|
+
function pushConfigStandard(config: PushConfig | null | undefined, target: string, remoteNamed: boolean, specNamed: boolean): boolean {
|
|
1923
|
+
if (config === null || config === undefined || typeof config !== 'object') return false
|
|
1924
|
+
const { push, remotePush, remoteMirror, remotePushDefault, branchPushRemote, submoduleRecurse } = config
|
|
1925
|
+
if (!Array.isArray(push) || !Array.isArray(remotePush) || ![remoteMirror, remotePushDefault, branchPushRemote, submoduleRecurse].every(measuredValue)) return false
|
|
1926
|
+
if (remotePush.length > 0 || (remoteMirror ?? 'false') !== 'false' || (submoduleRecurse ?? 'false') !== 'false') return false
|
|
1927
|
+
if (!remoteNamed && (branchPushRemote ?? remotePushDefault ?? target) !== target) return false
|
|
1928
|
+
return push.every(entry => {
|
|
1929
|
+
if (!Array.isArray(entry)) return false
|
|
1930
|
+
const key = String(entry[0]).toLowerCase()
|
|
1931
|
+
return SAFE_PUSH_KEYS.has(key) || (key === 'push.default' && (entry[1] === 'simple' || (specNamed && entry[1] === 'current')))
|
|
1932
|
+
})
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function readPush(args: readonly string[]): { pos: string[]; specs: string[] } {
|
|
1936
|
+
const pos = positionals(args, PUSH_VALUED)
|
|
1937
|
+
return { pos, specs: pos.slice(1) }
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const PLAIN_BRANCH = /^[A-Za-z0-9._/-]+$/
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* A destination git writes to as exactly `refs/heads/<name>`: no `@`, no `HEAD`, no prefix git
|
|
1944
|
+
* expands on its own (`heads/main` is refs/heads/main, `tags/v1` a tag), no other `refs/…`, and not
|
|
1945
|
+
* the `tag` of `git push origin tag v1`. `HEAD` in any case: on a case-insensitive filesystem git reads
|
|
1946
|
+
* `head` as HEAD (measured on macOS 2026-10-12: `git push origin head` on main printed `head -> main`).
|
|
1947
|
+
*/
|
|
1948
|
+
function isPlainBranch(name: string): boolean {
|
|
1949
|
+
return PLAIN_BRANCH.test(name) && !name.startsWith('-') && name.toLowerCase() !== 'head' && name !== 'tag' && !/^(?:refs|heads|tags|remotes)\//.test(name)
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
/** `main` or `master` in any case: a case-insensitive filesystem on either end may store `Main` as `main`. */
|
|
1953
|
+
function isProtected(name: string): boolean {
|
|
1954
|
+
return PROTECTED_BRANCHES.includes(name.toLowerCase())
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/** The remote a push goes to: the first positional, `--repo`, the branch's configured remote, else `origin`. */
|
|
1958
|
+
export function pushRemote(hit: Outward, configured: string | null): string {
|
|
1959
|
+
return readPush(hit.args).pos[0] ?? optionValue(hit.args, '--repo') ?? (configured || 'origin')
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* The branches a push writes to, or null when one cannot be named as a plain branch (no upstream for
|
|
1964
|
+
* a bare push, detached HEAD, a wildcard, `@`, `heads/…`, a `refs/` refspec other than refs/heads/):
|
|
1965
|
+
* such a push always needs confirmation. The wiring probes each returned name for a tag to fill
|
|
1966
|
+
* {@link PushFacts.tagRefs}.
|
|
1967
|
+
*/
|
|
1968
|
+
export function pushDestinations(hit: Outward, current: string | null, upstream: string | null, remote: string): string[] | null {
|
|
1969
|
+
if (hit.opaque === true) return null
|
|
1970
|
+
const { specs } = readPush(hit.args)
|
|
1971
|
+
const here = current ?? ''
|
|
1972
|
+
if (specs.length === 0) {
|
|
1973
|
+
if (!isPlainBranch(here) || upstream === null || upstream === '') return null
|
|
1974
|
+
const dst = upstream.startsWith(`${remote}/`) ? upstream.slice(remote.length + 1) : here
|
|
1975
|
+
return isPlainBranch(dst) ? [dst] : null
|
|
1976
|
+
}
|
|
1977
|
+
const dsts = specs.map(spec => {
|
|
1978
|
+
const bare = spec.replace(/^\+/, '')
|
|
1979
|
+
const target = (bare.includes(':') ? bare.slice(bare.indexOf(':') + 1) : bare).replace(/^refs\/heads\//, '')
|
|
1980
|
+
return target === '' || target === 'HEAD' ? here : target
|
|
1981
|
+
})
|
|
1982
|
+
return dsts.every(isPlainBranch) ? dsts : null
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* The source of each refspec a push names (the part before `:`, without `+`), whose ref the wiring
|
|
1987
|
+
* resolves into {@link PushFacts.sourceRefs}. `HEAD`, an empty source and a bare push name none:
|
|
1988
|
+
* `facts.current` (`--abbrev-ref HEAD`) already follows a symbolic ref to its branch, and git pushes
|
|
1989
|
+
* HEAD to that same branch (measured, git 2.56.0: HEAD → `feat/sym` → main printed `main` and
|
|
1990
|
+
* pushed `HEAD -> main`), so a HEAD on a symbolic ref is judged by its real branch already.
|
|
1991
|
+
*/
|
|
1992
|
+
export function pushSources(hit: Outward): string[] {
|
|
1993
|
+
return readPush(hit.args).specs
|
|
1994
|
+
.map(spec => spec.replace(/^\+/, '').split(':')[0] ?? '')
|
|
1995
|
+
.filter(src => src !== '' && src !== 'HEAD')
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/** The full name a source must resolve to: `refs/heads/<name>`, also when spelled with that prefix. */
|
|
1999
|
+
function branchRef(src: string): string {
|
|
2000
|
+
return src.startsWith('refs/heads/') ? src : `refs/heads/${src}`
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/** The {@link PushFacts.sourceRefs} of a push whose every source resolves as written; the wiring's permissive value. */
|
|
2004
|
+
export function plainSourceRefs(hit: Outward): string[] {
|
|
2005
|
+
return pushSources(hit).map(branchRef)
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Whether an outward action needs the owner's confirmation — an allowlist: a push skips it only
|
|
2010
|
+
* when every option is one of {@link PUSH_FLAGS} exactly; it names at most a plain remote and one
|
|
2011
|
+
* plain branch (no `:`, `+`, `@`, `main`/`master`, tag), or exactly `HEAD`, or with no branch the
|
|
2012
|
+
* measured destination is such a branch; every earlier command on the line is a git call in
|
|
2013
|
+
* {@link LOCAL_GIT}, and none is in {@link HOOK_GIT} unless `facts.hooks` is `false` and it ran in the
|
|
2014
|
+
* push's own folder ({@link Outward.gitBefore}: `<step> [other folder]` is not local), and with any of them
|
|
2015
|
+
* the repository's configuration names no program ({@link repoConfigStandard}); its config is in
|
|
2016
|
+
* {@link SAFE_CONFIG}; the push configuration is the standard one ({@link pushConfigStandard}); and every
|
|
2017
|
+
* fact was measured. Everything else, and every other kind, asks.
|
|
2018
|
+
*
|
|
2019
|
+
* The facts are measured before the line runs, so an earlier command may change them for every push
|
|
2020
|
+
* form (#1569 P3, MR !133 HOCH 2): one that is not git ({@link Outward.otherBefore}: `gh pr checkout 5`,
|
|
2021
|
+
* `./switch.sh`, `printf … >> .git/config`, one inside `$(…)`) may move HEAD or write the push
|
|
2022
|
+
* configuration, and so may a hook a git step fires. `HEAD` (`git push -u origin HEAD`, the usual first
|
|
2023
|
+
* push) and a bare push (no refspec, #1569 M1) then stand for `facts.current`; the wiring probes HEAD for
|
|
2024
|
+
* a tag like a named branch.
|
|
2025
|
+
* Detached (`current` is `HEAD` or null) it is no plain branch and asks; `HEAD:x`, `@` and
|
|
2026
|
+
* `refs/heads/HEAD` are not this form and ask.
|
|
2027
|
+
*/
|
|
2028
|
+
export function pushNeedsConfirm(hit: Outward, facts: PushFacts): boolean {
|
|
2029
|
+
if (hit.kind !== 'push' || hit.opaque === true) return true
|
|
2030
|
+
// `git switch main && git push`: the facts describe what was checked out before the line ran.
|
|
2031
|
+
if (hit.gitBefore?.some(step => !LOCAL_GIT.has(step)) === true) return true
|
|
2032
|
+
if (hit.otherBefore === true) return true
|
|
2033
|
+
if (facts.hooks !== false && hit.gitBefore?.some(step => HOOK_GIT.has(step)) === true) return true
|
|
2034
|
+
// #1573: `git status` runs core.fsmonitor, `git diff` diff.external, `git fetch` core.sshCommand — any of them may move HEAD.
|
|
2035
|
+
if ((hit.gitBefore?.length ?? 0) > 0 && !repoConfigStandard(facts.repoConfig, hit.gitBefore ?? [], hit.signsBefore === true)) return true
|
|
2036
|
+
// …and so does a submodule or embedded repository a step walks into: its own configuration was never read.
|
|
2037
|
+
if ((hit.gitBefore?.length ?? 0) > 0 && facts.gitlinks !== false) return true
|
|
2038
|
+
if (hit.gitConfig?.some(entry => !SAFE_CONFIG.test(entry)) === true) return true
|
|
2039
|
+
if (hit.args.some(arg => arg.startsWith('-') && !PUSH_FLAGS.has(arg))) return true
|
|
2040
|
+
const [remote, spec, ...extra] = hit.args.filter(arg => !arg.startsWith('-'))
|
|
2041
|
+
if (extra.length > 0 || (remote !== undefined && !PLAIN_REMOTE.test(remote))) return true
|
|
2042
|
+
if (!facts.remoteUrlKnown || facts.tagRefs === null) return true
|
|
2043
|
+
const target = pushRemote(hit, facts.remote)
|
|
2044
|
+
if (!pushConfigStandard(facts.pushConfig, target, remote !== undefined, spec !== undefined)) return true
|
|
2045
|
+
const named = spec === 'HEAD' ? facts.current : spec?.replace(/^refs\/heads\//, '')
|
|
2046
|
+
const dsts = spec === undefined ? pushDestinations(hit, facts.current, facts.upstream, target) : typeof named === 'string' ? [named] : null
|
|
2047
|
+
const tags = facts.tagRefs
|
|
2048
|
+
// #1570: a named source must be the branch it names, not a symbolic ref or a `refs/<name>` git picks first.
|
|
2049
|
+
const sourceRefs = facts.sourceRefs
|
|
2050
|
+
if (pushSources(hit).some((src, i) => !Array.isArray(sourceRefs) || sourceRefs[i] !== branchRef(src))) return true
|
|
2051
|
+
return dsts?.length !== 1 || dsts.some(d => !isPlainBranch(d) || isProtected(d) || tags.includes(d))
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// ---- Traps the rules name ---------------------------------------------------
|
|
2055
|
+
|
|
2056
|
+
export type Trap = { id: string; klasse: 'sec008' | 'regel'; title: string; why: string; fix: string }
|
|
2057
|
+
|
|
2058
|
+
// `$NAME`, `${NAME…}`, and zsh's parameter flags in front of the name that still expand the value: `${(U)NAME}`,
|
|
2059
|
+
// `${=NAME}`, `${~NAME}`, `${^NAME}` (#1699). Not `${#NAME}` (the length) or `${+NAME}` (set or not).
|
|
2060
|
+
const PARAM_OPEN = String.raw`\$(?:\{(?:\([^()]*\)|[=~^])*)?`
|
|
2061
|
+
const SECRET_VAR = new RegExp(String.raw`${PARAM_OPEN}((?:[A-Z][A-Z0-9_]*)?(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|_KEY|_PAT))\b`)
|
|
2062
|
+
|
|
2063
|
+
// .env, .envrc, .env.<x>, .env-<x>, .env_<x>, <name>.env, and a glob reaching them (`.env*`) — but not the committed templates.
|
|
2064
|
+
const ENV_FILE = /^(?:\.env(?:rc)?|\.env[._-][\w.-]+|[\w.-]+\.env|\.env[._-]?[*?[].*)$/
|
|
2065
|
+
const ENV_TEMPLATE = /(?:^|[._-])(?:example|sample|template|schema)(?:[._-]|$)/i
|
|
2066
|
+
|
|
2067
|
+
function isEnvFile(arg: string): boolean {
|
|
2068
|
+
const base = arg.replace(/^\d*</, '').split('/').pop() ?? ''
|
|
2069
|
+
return ENV_FILE.test(base) && !ENV_TEMPLATE.test(base)
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// Commands that print the content of a file they are given.
|
|
2073
|
+
const ENV_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'nl', 'strings', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'gawk', 'cut', 'sort', 'uniq', 'tac', 'rev', 'xxd', 'od', 'hexdump', 'hd', 'base64', 'base32', 'basenc', 'diff', 'paste', 'column', 'jq', 'yq', 'tr', 'fold', 'fmt', 'pr', 'expand', 'iconv'])
|
|
2074
|
+
|
|
2075
|
+
// `s/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p` under -n: the key of each KEY=value line and nothing else
|
|
2076
|
+
// (each class holds only the exact tokens A-Z, a-z, 0-9 and _, so it cannot span `=`: `[--z]` does).
|
|
2077
|
+
const SED_KEY_GROUP = /^s\/\^?\\\((?:\[(?:A-Z|a-z|0-9|_)+\][*+]?)+\\\)=\.\*\/\\1\/p$/
|
|
2078
|
+
// `s/=.*//`: strips the value of each line; accepted only as a filter behind an env dump.
|
|
2079
|
+
const SED_STRIP = /^s\/=\.\*\/\/g?$/
|
|
2080
|
+
const AWK_KEY = /^\{\s*print\s+\$1\s*;?\s*\}$/
|
|
2081
|
+
|
|
2082
|
+
const unquote = (value: string | undefined): string | undefined => value?.replace(/["']/g, '')
|
|
2083
|
+
|
|
2084
|
+
// A sed call whose one script prints keys only: the -n key-group form, and the value strip when `allowStrip`.
|
|
2085
|
+
function sedKeysOnly(args: readonly string[], allowStrip: boolean): boolean {
|
|
2086
|
+
let quiet = false
|
|
2087
|
+
const scripts: string[] = []
|
|
2088
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2089
|
+
const arg = args[i] ?? ''
|
|
2090
|
+
if (arg === '-n' || arg === '--quiet' || arg === '--silent') quiet = true
|
|
2091
|
+
else if (arg === '-e' || arg === '--expression') {
|
|
2092
|
+
scripts.push(args[i + 1] ?? '')
|
|
2093
|
+
i += 1
|
|
2094
|
+
} else if (arg === '-E' || arg === '-r' || arg === '--regexp-extended') continue
|
|
2095
|
+
else if (arg.startsWith('-')) return false
|
|
2096
|
+
else scripts.push(arg)
|
|
2097
|
+
}
|
|
2098
|
+
const [script] = scripts
|
|
2099
|
+
return scripts.length === 1 && script !== undefined && ((quiet && SED_KEY_GROUP.test(script)) || (allowStrip && SED_STRIP.test(script)))
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/** `cut -d= -f1` exactly (also `-d '='`, `--fields=1`, `-s`): `-f1-`, `-f1,2` and `--complement` print values. */
|
|
2103
|
+
function cutKeysOnly(args: readonly string[]): boolean {
|
|
2104
|
+
let delimiter: string | undefined
|
|
2105
|
+
let fields: string | undefined
|
|
2106
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2107
|
+
const arg = args[i] ?? ''
|
|
2108
|
+
if (arg === '-d' || arg === '--delimiter') {
|
|
2109
|
+
delimiter = args[i + 1]
|
|
2110
|
+
i += 1
|
|
2111
|
+
} else if (arg.startsWith('--delimiter=')) delimiter = arg.slice('--delimiter='.length)
|
|
2112
|
+
else if (arg.startsWith('-d')) delimiter = arg.slice(2)
|
|
2113
|
+
else if (arg === '-f' || arg === '--fields') {
|
|
2114
|
+
fields = args[i + 1]
|
|
2115
|
+
i += 1
|
|
2116
|
+
} else if (arg.startsWith('--fields=')) fields = arg.slice('--fields='.length)
|
|
2117
|
+
else if (arg.startsWith('-f')) fields = arg.slice(2)
|
|
2118
|
+
else if (arg !== '-s' && arg !== '--only-delimited') return false
|
|
2119
|
+
}
|
|
2120
|
+
return unquote(delimiter) === '=' && unquote(fields) === '1'
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/** `awk -F= '{print $1}'` exactly: any other program (`{print $1, $2}`) prints values. */
|
|
2124
|
+
function awkKeysOnly(args: readonly string[]): boolean {
|
|
2125
|
+
let separator: string | undefined
|
|
2126
|
+
const programs: string[] = []
|
|
2127
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2128
|
+
const arg = args[i] ?? ''
|
|
2129
|
+
if (arg === '-F') {
|
|
2130
|
+
separator = args[i + 1]
|
|
2131
|
+
i += 1
|
|
2132
|
+
} else if (arg.startsWith('-F')) separator = arg.slice(2)
|
|
2133
|
+
else if (arg.startsWith('-')) return false
|
|
2134
|
+
else programs.push(arg)
|
|
2135
|
+
}
|
|
2136
|
+
return unquote(separator) === '=' && programs.length === 1 && AWK_KEY.test(programs[0] ?? '')
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/** A pipeline stage that cuts an env dump down to its keys. */
|
|
2140
|
+
function isKeysOnlyFilter(stage: string): boolean {
|
|
2141
|
+
const [first, ...args] = commandWords(stage)
|
|
2142
|
+
const cmd = baseName(first ?? '')
|
|
2143
|
+
if (cmd === 'cut') return cutKeysOnly(args)
|
|
2144
|
+
if (cmd === 'awk' || cmd === 'gawk' || cmd === 'mawk') return awkKeysOnly(args)
|
|
2145
|
+
return cmd === 'sed' && sedKeysOnly(args, true)
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/** A reader of a .env file that prints no value: `grep -q`/`-c`/`-l`, or the keys-only sed of the trap's own fix. */
|
|
2149
|
+
function readsKeysOnly(cmd: string, args: readonly string[]): boolean {
|
|
2150
|
+
if (cmd === 'grep' || cmd === 'egrep' || cmd === 'fgrep' || cmd === 'rg') {
|
|
2151
|
+
return args.some(a => /^-[A-Za-z]*[qcl]/.test(a) || ['--quiet', '--silent', '--count', '--files-with-matches', '--files-without-match'].includes(a))
|
|
2152
|
+
}
|
|
2153
|
+
return cmd === 'sed' && sedKeysOnly(args.filter(a => !isEnvFile(a)), false)
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* Where a stage's output goes, from every output redirection read in order: `stdout` when fd 1 is redirected and
|
|
2158
|
+
* EVERY fd-1 target is `/dev/null` (zsh MULTIOS writes to each listed target, so `>&2 >/dev/null` still prints),
|
|
2159
|
+
* `stderr` likewise for fd 2, where `2>&1` counts only when fd 1 already points at `/dev/null` alone. `&>`, `>&file`
|
|
2160
|
+
* set both. A quoted, unknown or closed (`>&-`) target, or a descriptor copy elsewhere, keeps it printing (fail-closed).
|
|
2161
|
+
* `moved`: fd 1 itself is redirected anywhere (`>&2`, `>file`, `&>`), so a pipe after the stage receives none of it (#1649);
|
|
2162
|
+
* `>&1` copies fd 1 onto itself and moves nothing. An fd-1 or fd-2 `<&` / `<>` reopens that descriptor too: bash's
|
|
2163
|
+
* `>/dev/null 1<&2` points fd 1 back at stderr and prints (#1696), so it is read as `>&` / `>`; `0<&2` is stdin and moves no output.
|
|
2164
|
+
* `fileOk`: a file target that also counts as silent (the heredoc file writes, {@link safeFileTarget}); default none.
|
|
2165
|
+
*/
|
|
2166
|
+
function outputSilence(stage: string, fileOk: (target: string) => boolean = () => false): { stdout: boolean; stderr: boolean; moved: boolean } {
|
|
2167
|
+
const raw = rawWords(stripGroup(stage))
|
|
2168
|
+
const out = { set: false, silent: true }
|
|
2169
|
+
const err = { set: false, silent: true }
|
|
2170
|
+
let moved = false
|
|
2171
|
+
const send = (fd: typeof out, silent: boolean): void => {
|
|
2172
|
+
fd.set = true
|
|
2173
|
+
fd.silent &&= silent
|
|
2174
|
+
}
|
|
2175
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
2176
|
+
const m = /^(\d*|&)(>&|>>|>\||>)(.*)$/s.exec(raw[i] ?? '') ?? /^([12])(<&|<>)(.*)$/s.exec(raw[i] ?? '')
|
|
2177
|
+
if (m === null) continue
|
|
2178
|
+
const [, fd = '', written = '', inline = ''] = m
|
|
2179
|
+
const op = written === '<&' ? '>&' : written === '<>' ? '>' : written
|
|
2180
|
+
let to = inline
|
|
2181
|
+
if (to === '') {
|
|
2182
|
+
i += 1
|
|
2183
|
+
to = raw[i] ?? ''
|
|
2184
|
+
}
|
|
2185
|
+
const copy = op === '>&' && /^(?:\d+|-)$/.test(to)
|
|
2186
|
+
const silent = to === '/dev/null' || fileOk(to)
|
|
2187
|
+
if (fd === '2') send(err, copy ? to === '1' && out.set && out.silent : silent)
|
|
2188
|
+
else if (fd === '' || fd === '1') {
|
|
2189
|
+
moved ||= !(copy && to === '1')
|
|
2190
|
+
send(out, !copy && silent)
|
|
2191
|
+
if (op === '>&' && !copy) send(err, silent)
|
|
2192
|
+
} else if (fd === '&') {
|
|
2193
|
+
moved = true
|
|
2194
|
+
send(out, silent)
|
|
2195
|
+
send(err, silent)
|
|
2196
|
+
} else {
|
|
2197
|
+
// fd 3 and above: a copy of stdout or stderr (`3>&1 >/dev/null`) lets the program write past the /dev/null.
|
|
2198
|
+
send(out, false)
|
|
2199
|
+
send(err, false)
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
return { stdout: out.set && out.silent, stderr: err.set && err.silent, moved }
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Digests print a hash of what they read, never the input: `-c`/`--check` (reads a checklist, prints its
|
|
2206
|
+
// names) and BSD md5's `-p` (echoes stdin) are excluded below.
|
|
2207
|
+
const DIGEST = /^(?:sha\d*sum|shasum|md5sum|md5|b2sum|b3sum|cksum)$/
|
|
2208
|
+
|
|
2209
|
+
/** `curl --config -` / `-K -` / `--config=-` / `-K-`: the config, and the secret in it, is read from stdin. */
|
|
2210
|
+
function curlConfigStdin(args: readonly string[]): boolean {
|
|
2211
|
+
return args.some((arg, i) => ((arg === '-K' || arg === '--config') && args[i + 1] === '-') || arg === '-K-' || arg === '--config=-')
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// The curl options that print what the config set: `-v` / `--verbose` and `--trace*` the request headers,
|
|
2215
|
+
// `--libcurl` the options as C code (#1706 item 1). A short group counts when its letters reach a `v` (`-sv`).
|
|
2216
|
+
// `-w` / `--write-out` is read by {@link curlPrints}. curl 8.x accepts the same long options that take an
|
|
2217
|
+
// argument under an `--expand-` prefix (`--expand-libcurl`, `--expand-write-out`), so the prefix counts too.
|
|
2218
|
+
// NAMED CEILING (BV-004): a `verbose` / `trace` / `write-out` line inside the stdin config itself is not read; revisit
|
|
2219
|
+
// if a config that sets them shows up.
|
|
2220
|
+
const CURL_PRINTS = /^(?:-[A-Za-z]*v|--(?:verbose|(?:expand-)?(?:trace(?:-ascii)?|libcurl))(?:=|$))/
|
|
2221
|
+
// The `-w` variables that print only status, counts, sizes and timings, never a value the config set. An allow list
|
|
2222
|
+
// on purpose: `%{url}`, `%{url_effective}`, `%{url.*}`, `%{urle.*}`, `%{json}`, `%{header_json}`, `%header{…}`,
|
|
2223
|
+
// `%{referer}`, `%{errormsg}` and every variable a later curl adds count as printing.
|
|
2224
|
+
const CURL_QUIET_VARS: ReadonlySet<string> = new Set([
|
|
2225
|
+
'http_code', 'response_code', 'http_connect', 'http_version', 'exitcode', 'num_connects', 'num_redirects',
|
|
2226
|
+
'num_headers', 'num_retries', 'size_download', 'size_upload', 'size_header', 'size_request', 'speed_download',
|
|
2227
|
+
'speed_upload', 'ssl_verify_result', 'proxy_ssl_verify_result', 'time_appconnect', 'time_connect',
|
|
2228
|
+
'time_namelookup', 'time_pretransfer', 'time_posttransfer', 'time_queue', 'time_redirect', 'time_starttransfer',
|
|
2229
|
+
'time_total', 'stdout', 'stderr',
|
|
2230
|
+
])
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* Whether a `-w` / `--write-out` format can print what the config set: a `@file` or `$`/backtick format (unreadable
|
|
2234
|
+
* here), or any `%` that is not `%%` or a {@link CURL_QUIET_VARS} variable (`-w "%{http_code}\n"` prints nothing of it).
|
|
2235
|
+
*/
|
|
2236
|
+
function writeOutPrints(format: string | undefined): boolean {
|
|
2237
|
+
if (format === undefined || format.startsWith('@') || /[$`]/.test(format)) return true
|
|
2238
|
+
for (const m of format.replace(/%%/g, '').matchAll(/%(?:\{([^}]*)\})?/g)) if (!CURL_QUIET_VARS.has(m[1] ?? '')) return true
|
|
2239
|
+
return false
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* Whether a curl command line prints the config it read: {@link CURL_PRINTS}, or a `-w` / `--write-out` whose format
|
|
2244
|
+
* {@link writeOutPrints} (`-w FMT`, `-wFMT`, `-sw FMT`, `--write-out FMT`, `--write-out=FMT`, and the same with
|
|
2245
|
+
* `--expand-write-out`, whose `{{variable}}` may hold any format and so counts as printing).
|
|
2246
|
+
*/
|
|
2247
|
+
function curlPrints(args: readonly string[]): boolean {
|
|
2248
|
+
return args.some((arg, i) => {
|
|
2249
|
+
if (CURL_PRINTS.test(arg)) return true
|
|
2250
|
+
const long = /^--(expand-)?write-out(?:=(.*))?$/s.exec(arg)
|
|
2251
|
+
if (long !== null) {
|
|
2252
|
+
const format = long[2] ?? args[i + 1]
|
|
2253
|
+
return (long[1] !== undefined && format?.includes('{{') === true) || writeOutPrints(format)
|
|
2254
|
+
}
|
|
2255
|
+
// A short group: the letters after `w` are its value, otherwise the next word is.
|
|
2256
|
+
const group = /^-[A-Za-z]*?w(.*)$/s.exec(arg)
|
|
2257
|
+
if (group === null || arg.startsWith('--')) return false
|
|
2258
|
+
return writeOutPrints(group[1] === '' ? args[i + 1] : group[1])
|
|
2259
|
+
})
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// Registry clients whose `login --password-stdin` reads the password from stdin and prints only a status line.
|
|
2263
|
+
const STDIN_LOGIN = /^(?:docker|podman|nerdctl|buildah|skopeo|oras)$/
|
|
2264
|
+
// Commands that run other commands or shell code: what they run may print stdin anywhere (`sh -c "cat >/dev/tty"`).
|
|
2265
|
+
const RUNNERS = new Set(['xargs', 'parallel', 'sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'su', 'script', 'env', 'exec', 'command', 'nohup', 'time', 'eval', 'sudo', 'nice', 'timeout', 'gtimeout', 'caffeinate', 'builtin'])
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* A named login that takes its secret on stdin silently, the flag read at the login's own position:
|
|
2269
|
+
* `docker|podman|nerdctl|buildah|skopeo|oras login … --password-stdin`, `helm registry login … --password-stdin`,
|
|
2270
|
+
* `crane auth login … --password-stdin`, `gh auth login … --with-token`, `glab auth login … --stdin`.
|
|
2271
|
+
*/
|
|
2272
|
+
function stdinLogin(cmd: string, args: readonly string[]): boolean {
|
|
2273
|
+
if (STDIN_LOGIN.test(cmd)) return args[0] === 'login' && args.includes('--password-stdin')
|
|
2274
|
+
if (cmd === 'helm') return args[0] === 'registry' && args[1] === 'login' && args.includes('--password-stdin')
|
|
2275
|
+
if (cmd === 'crane') return args[0] === 'auth' && args[1] === 'login' && args.includes('--password-stdin')
|
|
2276
|
+
if (cmd === 'gh') return args[0] === 'auth' && args[1] === 'login' && args.includes('--with-token')
|
|
2277
|
+
if (cmd === 'glab') return args[0] === 'auth' && args[1] === 'login' && args.includes('--stdin')
|
|
2278
|
+
return false
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// The compound commands whose body inherits the stdin of their head (`while read -r l; do echo "$l"; done`).
|
|
2282
|
+
const COMPOUND_HEADS = new Set(['while', 'until', 'if', 'for', 'select', 'case'])
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* Whether a stage is only the head of a group or compound command that the pipeline split cut at its `;` or `&&`
|
|
2286
|
+
* (`{ true >/dev/null 2>&1; cat; }`, `while read -r l; do echo "$l"; done`): the commands behind the cut read the
|
|
2287
|
+
* same stdin and this stage cannot see them, so it never counts as silent (#1649, fail-closed). More leading `(`/`{`
|
|
2288
|
+
* than trailing `)`/`}` means the group runs on; one pass from each end. NAMED CEILING (BV-004): a whole group of
|
|
2289
|
+
* silent commands (`{ sha256sum; }`) is refused too instead of being read across the cut; revisit if such a form is
|
|
2290
|
+
* needed in practice.
|
|
2291
|
+
*/
|
|
2292
|
+
function cutCompound(stage: string): boolean {
|
|
2293
|
+
let open = 0
|
|
2294
|
+
let i = 0
|
|
2295
|
+
for (; i < stage.length && /[\s({]/.test(stage[i] ?? ''); i += 1) if (stage[i] === '(' || stage[i] === '{') open += 1
|
|
2296
|
+
let close = 0
|
|
2297
|
+
for (let j = stage.length; j > i && /[\s)}]/.test(stage[j - 1] ?? ''); j -= 1) if (stage[j - 1] === ')' || stage[j - 1] === '}') close += 1
|
|
2298
|
+
if (open > close) return true
|
|
2299
|
+
for (const word of argWords(stripGroup(stage))) {
|
|
2300
|
+
if (COMPOUND_HEADS.has(word)) return true
|
|
2301
|
+
if (!PREFIXES.has(word)) break
|
|
2302
|
+
}
|
|
2303
|
+
return false
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* The allow list part of {@link consumesSilently}: a command that prints no part of what it reads wherever its
|
|
2308
|
+
* output goes (a digest, `openssl dgst`, `wc`, `read`, `curl --config -`, a named stdin login).
|
|
2309
|
+
*/
|
|
2310
|
+
function printsNoInput(stage: string): boolean {
|
|
2311
|
+
if (cutCompound(stage)) return false
|
|
2312
|
+
const [first, ...args] = commandWords(stage)
|
|
2313
|
+
const cmd = baseName(first ?? '')
|
|
2314
|
+
if (DIGEST.test(cmd)) return !args.some(a => a === '--check' || /^-[A-Za-z]*[cp]/.test(a))
|
|
2315
|
+
if (cmd === 'openssl') return /^(?:dgst|sha\d*|md5)$/.test(args[0] ?? '')
|
|
2316
|
+
if (cmd === 'wc' || cmd === 'read') return true
|
|
2317
|
+
if (cmd === 'curl' && curlConfigStdin(args)) return !curlPrints(args)
|
|
2318
|
+
return stdinLogin(cmd, args)
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* The variables a `read` stage sets (`read -r a b`, `IFS= read -r l`, `command read x`, `builtin read x`, zsh's
|
|
2323
|
+
* `read 'x?prompt'`), or null when the stage is no `read`. REPLY always counts: an option value (`-p Enter`) is
|
|
2324
|
+
* not told apart from a name, so the default variable is never lost. NAMED CEILING (BV-004): option values are read
|
|
2325
|
+
* as names too (more names, never fewer); revisit if that taints a name that is printed in practice.
|
|
2326
|
+
*/
|
|
2327
|
+
function readNames(stage: string): string[] | null {
|
|
2328
|
+
if (cutCompound(stage)) return null
|
|
2329
|
+
const words = commandWords(stage)
|
|
2330
|
+
const at = words[0] === 'builtin' ? 1 : 0
|
|
2331
|
+
if (baseName(words[at] ?? '') !== 'read') return null
|
|
2332
|
+
const names = words.slice(at + 1).map(w => w.split('?')[0] ?? '').filter(w => /^[A-Za-z_][A-Za-z0-9_]*$/.test(w))
|
|
2333
|
+
return [...names, 'REPLY']
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
const VAR_REF = new RegExp(String.raw`${PARAM_OPEN}([A-Za-z_][A-Za-z0-9_]*)`, 'g')
|
|
2337
|
+
|
|
2338
|
+
/** The first secret variable a stage expands: a secret-shaped name, or one a `read` filled with a secret (#1695). */
|
|
2339
|
+
function secretRef(text: string, tainted: ReadonlySet<string>): string | undefined {
|
|
2340
|
+
const named = SECRET_VAR.exec(text)?.[1]
|
|
2341
|
+
if (named !== undefined || tainted.size === 0) return named
|
|
2342
|
+
for (const m of text.matchAll(VAR_REF)) if (tainted.has(m[1] ?? '')) return m[1]
|
|
2343
|
+
return undefined
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* A stage that reads a secret from stdin and prints none of it (an allow list, #1569 point 1): a digest,
|
|
2348
|
+
* `openssl dgst`, `wc`, `read`, `curl --config -`, a named stdin login ({@link stdinLogin}), or a command other than
|
|
2349
|
+
* `tee` and a runner whose stdout AND stderr both go only to `/dev/null` ({@link outputSilence}). Everything else
|
|
2350
|
+
* (`perl -pe1`, `gzip`, `base64`, `python3 -c …`, `xargs echo --password-stdin`) counts as re-printing.
|
|
2351
|
+
*/
|
|
2352
|
+
function consumesSilently(stage: string): boolean {
|
|
2353
|
+
if (cutCompound(stage)) return false
|
|
2354
|
+
if (printsNoInput(stage)) return true
|
|
2355
|
+
const [first] = commandWords(stage)
|
|
2356
|
+
const cmd = baseName(first ?? '')
|
|
2357
|
+
// A runner stripped as a prefix by commandWords (`eval`, `exec`, `sudo`) still counts: any word before the command.
|
|
2358
|
+
// Unlocated (-1): every word is read, fail-closed. A cut-out `sh -c '…'` / `eval '…'` sits here as its placeholder word.
|
|
2359
|
+
const words = argWords(stripGroup(stage))
|
|
2360
|
+
const at = words.indexOf(first ?? '')
|
|
2361
|
+
if (cmd === 'tee' || (at === -1 ? words : words.slice(0, at + 1)).some(w => RUNNERS.has(baseName(w)) || INNER_WORD.test(w))) return false
|
|
2362
|
+
const silence = outputSilence(stage)
|
|
2363
|
+
return silence.stdout && silence.stderr
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// The env options that take a separate value; `-S` / `--split-string` runs its string as a command.
|
|
2367
|
+
const ENV_DUMP_VALUED = new Set(['-u', '--unset', '-C', '--chdir'])
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* Whether the words (prefixes already removed) print variable values, judged by the command and its
|
|
2371
|
+
* arguments, not by their number: `env`/`env -0`/`env -i` with no command left, `printenv` with no name or a
|
|
2372
|
+
* secret one, bare `export`/`readonly` (options only), bare `set` (`set -e` sets options), `declare`/`typeset`
|
|
2373
|
+
* with no name (`declare -x`, `-p`) or with a set secret name and no `=` (zsh prints it). `env FOO=1 cmd`
|
|
2374
|
+
* runs cmd; `export FOO=bar` and `declare -i n` print nothing. A secret name is secret-shaped or one of
|
|
2375
|
+
* `tainted` (a `read` or an assignment filled it with a secret: `read x; printenv x`, #1699).
|
|
2376
|
+
*/
|
|
2377
|
+
function dumpsEnvironment(words: readonly string[], tainted: ReadonlySet<string> = new Set()): boolean {
|
|
2378
|
+
const [first, ...args] = words
|
|
2379
|
+
const cmd = baseName(first ?? '')
|
|
2380
|
+
const secretName = (name: string): boolean => SECRET_VAR.test(`$${name}`) || tainted.has(name)
|
|
2381
|
+
if (cmd === 'env') {
|
|
2382
|
+
let i = 0
|
|
2383
|
+
while (i < args.length) {
|
|
2384
|
+
const arg = args[i] ?? ''
|
|
2385
|
+
if (arg === '--') {
|
|
2386
|
+
i += 1
|
|
2387
|
+
break
|
|
2388
|
+
}
|
|
2389
|
+
if (arg.startsWith('-S') || arg.startsWith('--split-string')) return false
|
|
2390
|
+
if (arg === '-' || !arg.startsWith('-')) break
|
|
2391
|
+
i += ENV_DUMP_VALUED.has(arg) ? 2 : 1
|
|
2392
|
+
}
|
|
2393
|
+
while (i < args.length && ASSIGNMENT.test(args[i] ?? '')) i += 1
|
|
2394
|
+
return i >= args.length
|
|
2395
|
+
}
|
|
2396
|
+
// printenv prints every variable it is given: one secret name among several still prints (#1572 T2).
|
|
2397
|
+
if (cmd === 'printenv') {
|
|
2398
|
+
const names = args.filter(a => !a.startsWith('-'))
|
|
2399
|
+
return names.length === 0 || names.some(secretName)
|
|
2400
|
+
}
|
|
2401
|
+
if (cmd === 'export' || cmd === 'readonly') return args.every(a => a.startsWith('-'))
|
|
2402
|
+
if (cmd === 'set') return args.length === 0
|
|
2403
|
+
if (cmd === 'declare' || cmd === 'typeset') {
|
|
2404
|
+
if (args.some(a => /^-[A-Za-z]*[fF]/.test(a))) return false
|
|
2405
|
+
const names = args.filter(a => !/^[-+]/.test(a))
|
|
2406
|
+
return names.length === 0 || names.some(name => !name.includes('=') && secretName(name))
|
|
2407
|
+
}
|
|
2408
|
+
return false
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Every trap the rules name in a shell command, once per id, in reading order. All of them, not the
|
|
2413
|
+
* first: on `locker` a `regel` trap only warns, so `git add -A && cat .env` must still carry the SEC-008 one.
|
|
2414
|
+
*/
|
|
2415
|
+
export function findTraps(command: string): Trap[] {
|
|
2416
|
+
const line = dataView(joinContinuations(command))
|
|
2417
|
+
const found = collectTraps(line, 0, line, new Set(), command)
|
|
2418
|
+
return found.filter((trap, i) => found.findIndex(other => other.id === trap.id) === i)
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* `raw`: the text the heredoc reader scans — the command as written at the top (the data view drops text heredocs,
|
|
2423
|
+
* bodies and all), the inner command line itself below it.
|
|
2424
|
+
*/
|
|
2425
|
+
function collectTraps(line: string, depth: number, root: string, inherited: ReadonlySet<string> = new Set(), raw = line): Trap[] {
|
|
2426
|
+
const found: Trap[] = []
|
|
2427
|
+
const { text, inners } = extractInner(line, depth)
|
|
2428
|
+
// The read taint of the whole line, so an inner `sh -c "echo $x"` / `eval "echo $x"` sees the `read x` before it
|
|
2429
|
+
// (#1695). Ceiling: a `read` AFTER the inner command taints it too (fail-closed over-detection); revisit if that
|
|
2430
|
+
// false alarm shows up in practice.
|
|
2431
|
+
const lineTaint = new Set(inherited)
|
|
2432
|
+
for (const stages of splitPipelines(text)) echoedSecrets(stages, lineTaint)
|
|
2433
|
+
// A child shell (`sh -c '…'`) reads an exported name from its environment, single quotes or not (#1699):
|
|
2434
|
+
// `read x; export x; sh -c 'echo $x'`. Ceiling as above: an export after the inner command counts too; `set -a`
|
|
2435
|
+
// (export everything) is not read — revisit if it shows up in practice.
|
|
2436
|
+
const exported = new Set<string>()
|
|
2437
|
+
for (const segment of splitSegments(text)) {
|
|
2438
|
+
const [head, ...rest] = commandWords(segment)
|
|
2439
|
+
if (head !== 'export' && !(DECLARERS.has(head ?? '') && rest.some(arg => /^-[A-Za-z]*x/.test(arg)))) continue
|
|
2440
|
+
for (const arg of rest) exported.add(/^[A-Za-z_][A-Za-z0-9_]*/.exec(arg)?.[0] ?? '')
|
|
2441
|
+
}
|
|
2442
|
+
const innerTaint = (inner: Inner): Set<string> => {
|
|
2443
|
+
const names = [...inner.expanded.matchAll(VAR_REF)].map(m => m[1] ?? '').filter(name => lineTaint.has(name))
|
|
2444
|
+
if (!inner.sameShell) for (const m of inner.command.matchAll(VAR_REF)) if (exported.has(m[1] ?? '') && lineTaint.has(m[1] ?? '')) names.push(m[1] ?? '')
|
|
2445
|
+
return new Set(names)
|
|
2446
|
+
}
|
|
2447
|
+
for (const segment of splitSegments(text)) {
|
|
2448
|
+
const words = commandWords(segment)
|
|
2449
|
+
const [first, ...args] = words
|
|
2450
|
+
if (first === undefined) continue
|
|
2451
|
+
const inner = innerAt(first, inners)
|
|
2452
|
+
if (inner !== null) {
|
|
2453
|
+
found.push(...collectTraps(inner.command, depth + 1, root, innerTaint(inner)))
|
|
2454
|
+
continue
|
|
2455
|
+
}
|
|
2456
|
+
// A cut-out `bash -c '…'` behind another word still runs (`flock /tmp/l bash -c "cat .env"`, #1572 T1).
|
|
2457
|
+
for (const word of DATA_COMMANDS.has(baseName(first)) ? [] : args) {
|
|
2458
|
+
const later = innerAt(word, inners)
|
|
2459
|
+
if (later !== null) found.push(...collectTraps(later.command, depth + 1, root, innerTaint(later)))
|
|
2460
|
+
}
|
|
2461
|
+
const cmd = baseName(first)
|
|
2462
|
+
|
|
2463
|
+
if (
|
|
2464
|
+
cmd === 'claude' &&
|
|
2465
|
+
args.some(a => a === '-p' || a === '--print') &&
|
|
2466
|
+
!/env\s+(-\S+\s+)*-u\s+ANTHROPIC_API_KEY\b|unset\s+ANTHROPIC_API_KEY\b/.test(root)
|
|
2467
|
+
) {
|
|
2468
|
+
found.push({
|
|
2469
|
+
id: 'claude-p-oauth',
|
|
2470
|
+
klasse: 'regel',
|
|
2471
|
+
title: 'claude -p ohne env -u ANTHROPIC_API_KEY',
|
|
2472
|
+
why: 'claude -p erbt sonst den ANTHROPIC_API_KEY aus älteren Shells und rechnet über die bezahlte API ab statt über OAuth (Memory "claude -p nur über OAuth").',
|
|
2473
|
+
fix: `env -u ANTHROPIC_API_KEY ${segment.trim()}`,
|
|
2474
|
+
})
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
if (ENV_READERS.has(cmd) && args.some(isEnvFile) && !readsKeysOnly(cmd, args)) {
|
|
2478
|
+
const file = args.find(isEnvFile) ?? '.env'
|
|
2479
|
+
found.push({
|
|
2480
|
+
id: 'sec008-env-datei',
|
|
2481
|
+
klasse: 'sec008',
|
|
2482
|
+
title: `${cmd} ${file}`,
|
|
2483
|
+
why: 'Gibt Secret-Werte in Transkript und Log aus (SEC-008: never emit, do not mask).',
|
|
2484
|
+
fix: `sed -n 's/^\\([A-Z_][A-Z0-9_]*\\)=.*/\\1/p' ${file} # nur die Schlüssel`,
|
|
2485
|
+
})
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
if (cmd === 'docker' && (args[0] === 'inspect' || (args[0] === 'container' && args[1] === 'inspect'))) {
|
|
2489
|
+
const formatted = args.some(a => a === '-f' || a === '--format' || a.startsWith('--format=') || /^-f./.test(a))
|
|
2490
|
+
if (!formatted) {
|
|
2491
|
+
found.push({
|
|
2492
|
+
id: 'sec008-docker-inspect',
|
|
2493
|
+
klasse: 'sec008',
|
|
2494
|
+
title: 'docker inspect ohne --format',
|
|
2495
|
+
why: 'docker inspect druckt Config.Env im Klartext (SEC-008).',
|
|
2496
|
+
fix: "docker exec <container> printenv | cut -s -d= -f1 | sort # oder: docker inspect -f '{{.State.Status}}' <container>",
|
|
2497
|
+
})
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
if (cmd === 'git' && args[0] === 'add' && args.slice(1).some(a => a === '.' || a === '-A' || a === '--all' || a === ':/')) {
|
|
2502
|
+
found.push({
|
|
2503
|
+
id: 'commit-discipline-add',
|
|
2504
|
+
klasse: 'regel',
|
|
2505
|
+
title: 'git add . / -A / --all',
|
|
2506
|
+
why: 'Pauschales Stagen nimmt die Arbeit paralleler Sessions mit (commit-discipline, PSA-004).',
|
|
2507
|
+
fix: 'git add <datei> <datei> … # einzeln nach Namen, dann git diff --cached prüfen',
|
|
2508
|
+
})
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
if (cmd === 'curl') {
|
|
2512
|
+
if (/-H\s*["']?Authorization:\s*(Bearer|token)\s+\$|--header\s*["']?Authorization:\s*(Bearer|token)\s+\$/i.test(segment)) {
|
|
2513
|
+
found.push({
|
|
2514
|
+
id: 'sec008-secret-argv',
|
|
2515
|
+
klasse: 'sec008',
|
|
2516
|
+
title: 'Token in curl-Argumenten',
|
|
2517
|
+
why: 'Argumente sind für jeden lokalen User in ps lesbar und landen in Shell-History und Logs (SEC-008).',
|
|
2518
|
+
fix: `printf 'header = "Authorization: Bearer %s"' "$TOKEN" | curl --config - <url>`,
|
|
2519
|
+
})
|
|
2520
|
+
}
|
|
2521
|
+
if (/[?&](access_token|private_token|api_key|apikey|token|key)=\$/i.test(segment)) {
|
|
2522
|
+
found.push({
|
|
2523
|
+
id: 'sec008-token-in-url',
|
|
2524
|
+
klasse: 'sec008',
|
|
2525
|
+
title: 'Token in der URL',
|
|
2526
|
+
why: 'Query-Strings landen in Server-, Proxy- und Referer-Logs (SEC-008).',
|
|
2527
|
+
fix: 'Token als Header über curl --config - übergeben, nie in der URL.',
|
|
2528
|
+
})
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
if (/\$\{?PIPESTATUS\[/.test(text)) {
|
|
2534
|
+
found.push({
|
|
2535
|
+
id: 'zsh-pipestatus',
|
|
2536
|
+
klasse: 'regel',
|
|
2537
|
+
title: '${PIPESTATUS[0]} in zsh',
|
|
2538
|
+
why: 'Die Shell hier ist zsh: ${PIPESTATUS[0]} ist leer, und ein leeres EXIT= liest sich wie Erfolg (bash-harness-pitfalls §6).',
|
|
2539
|
+
fix: 'cmd > /tmp/out.log 2>&1; rc=$?; tail -5 /tmp/out.log; echo "EXIT=$rc"',
|
|
2540
|
+
})
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
if (/\bgrep\s+(?:-[A-Za-z]*c[A-Za-z]*|--count)\b[^\n]*?\|\|\s*echo\s+["']?0["']?/.test(text)) {
|
|
2544
|
+
found.push({
|
|
2545
|
+
id: 'grep-c-echo-0',
|
|
2546
|
+
klasse: 'regel',
|
|
2547
|
+
title: 'grep -c … || echo 0',
|
|
2548
|
+
why: 'grep -c druckt bei keinem Treffer selbst 0 und endet mit 1: das Ergebnis ist "0\\n0" und bricht jeden Zahlenvergleich (bash-harness-pitfalls §1).',
|
|
2549
|
+
fix: 'count=$(grep -c "MUSTER" datei || true)',
|
|
2550
|
+
})
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
const tainted = new Set(inherited)
|
|
2554
|
+
for (const stages of splitPipelines(text)) {
|
|
2555
|
+
// Not commandWords: a bare `env` is the command here, not a prefix. `if env; then` runs it too,
|
|
2556
|
+
// and so do `(env)`, `command env`, `builtin set` and `FOO=1 env`.
|
|
2557
|
+
const head = argWords(stripGroup(stages[0] ?? ''), true)
|
|
2558
|
+
while (PREFIXES.has(head[0] ?? '') || head[0] === 'builtin' || ASSIGNMENT.test(head[0] ?? '')) head.shift()
|
|
2559
|
+
// The keys-only filter must read the dump directly: `env | tee /dev/stderr | cut -d= -f1` printed it first.
|
|
2560
|
+
// `printenv NAME` prints the bare value with no `=`, so no filter makes it keys-only.
|
|
2561
|
+
const filtered = !(baseName(head[0] ?? '') === 'printenv' && head.slice(1).some(a => !a.startsWith('-'))) && isKeysOnlyFilter(stages[1] ?? '')
|
|
2562
|
+
if (dumpsEnvironment(head, tainted) && !filtered) {
|
|
2563
|
+
found.push({
|
|
2564
|
+
id: 'sec008-env-dump',
|
|
2565
|
+
klasse: 'sec008',
|
|
2566
|
+
title: `${head.join(' ')} ohne Filter`,
|
|
2567
|
+
why: 'Ein Umgebungs-Dump druckt jeden Token-Wert ins Transkript (SEC-008).',
|
|
2568
|
+
fix: 'env | cut -s -d= -f1 | sort # Schlüssel; Einzelwert: test -n "${VAR:-}" && echo "VAR: set"',
|
|
2569
|
+
})
|
|
2570
|
+
}
|
|
2571
|
+
found.push(...echoedSecrets(stages, tainted))
|
|
2572
|
+
}
|
|
2573
|
+
const heredoc = heredocTrap(raw, lineTaint)
|
|
2574
|
+
if (heredoc !== null) found.push(heredoc)
|
|
2575
|
+
return found
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// A plain file name the heredoc file exemption accepts: relative segments or `~/` in front, no `..`, no leading `/`
|
|
2579
|
+
// (`//dev/stdout`, `/dev/fd/1`, `/proc/self/fd/1`), no quote, `$`, glob or leading `-`. NAMED CEILING (BV-004): an
|
|
2580
|
+
// absolute path (`/tmp/x`) and a symlink planted by an earlier command are not told apart from a device, so the
|
|
2581
|
+
// first is refused and the second allowed; revisit if `cat > /tmp/x <<EOF` with a secret is needed in practice.
|
|
2582
|
+
// A relative name resolves against the cwd and `~/` against HOME, so a text that changes either (`cd /dev; cat >
|
|
2583
|
+
// stdout`, `HOME=/dev; cat > ~/stdout`, #1696 M6) gets no exemption at all ({@link movesTargets}). The cwd a
|
|
2584
|
+
// previous Bash call left behind, and zsh AUTO_CD (a bare directory name changes it), stay invisible: named ceiling.
|
|
2585
|
+
// movesTargets is a denylist over the raw text, so deliberate obfuscation passes it (`c\d /dev`, `'c'd /dev`,
|
|
2586
|
+
// `eval 'HO''ME=/dev'`, zsh `chdir` and `${HOME::=/dev}`, measured by the #1696 review round 2; CDPATH is matched in
|
|
2587
|
+
// every form, `${CDPATH:=…}` included, see {@link MOVES_HOME}); revisit with a
|
|
2588
|
+
// one-statement-only exemption when that becomes a real attack shape rather than a constructed one.
|
|
2589
|
+
const SAFE_FILE = /^(?:~\/)?(?!-)(?:[\w.@+,=%-]+\/)*[\w.@+,=%-]+$/
|
|
2590
|
+
const TEE_OPTIONS = new Set(['-a', '--append', '-i', '--ignore-interrupts'])
|
|
2591
|
+
// A `cd` / `pushd` / `popd` word anywhere (quoted or inner commands included), the word after it, and the start of a
|
|
2592
|
+
// second operand on the same line (zsh `cd old new` swaps `old` for `new` inside the cwd).
|
|
2593
|
+
const MOVES_CWD = /(?<![\w./-])(cd|pushd|popd)(?![\w./-])[ \t]*([^\s;&|()<>]*)([ \t]+[^\s;&|()<>])?/g
|
|
2594
|
+
// A HOME that is not only expanded, and any mention of CDPATH or zsh's tied `cdpath` array: CDPATH sends a relative
|
|
2595
|
+
// `cd fd` to `/dev/fd` (`CDPATH=/dev cd fd`, `cdpath=(/dev)`, `: ${CDPATH:=/dev}`). CDPATH is usually unset, so an
|
|
2596
|
+
// assign-on-expand form takes effect; it counts even only expanded (`$CDPATH`), a false alarm accepted (fail-closed).
|
|
2597
|
+
// HOME is always set, so `${HOME:=…}` changes nothing; `${HOME::=…}` stays the obfuscation ceiling above.
|
|
2598
|
+
// NAMED CEILING (BV-004): a CDPATH exported by an earlier Bash call is not in the text; revisit if one shows up.
|
|
2599
|
+
const MOVES_HOME = /(?<![\w${])HOME\b|(?<!\w)(?:CDPATH|cdpath)\b/
|
|
2600
|
+
// A directory a `cd` may move to and keep the exemption: plain segments, absolute, relative or `~/`, no `..`.
|
|
2601
|
+
const SAFE_DIR = /^(?:~\/|\/)?(?!-)(?:[\w.@+,=%-]+\/)*[\w.@+,=%-]+\/?$/
|
|
2602
|
+
// The first segments under which a device sits (`/dev`, `/dev/fd`, `/proc/self/fd`), also relative to a cwd of `/`;
|
|
2603
|
+
// compared lower-cased, since APFS resolves `/DEV/stdout` and `/Dev/Fd` to the devices.
|
|
2604
|
+
const DEVICE_ROOTS: ReadonlySet<string> = new Set(['dev', 'proc'])
|
|
2605
|
+
|
|
2606
|
+
function safeFileTarget(target: string): boolean {
|
|
2607
|
+
return SAFE_FILE.test(target) && !target.split('/').includes('..')
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* Whether the text may move the cwd or HOME a relative heredoc file target resolves against (#1696 M6). A `cd` /
|
|
2612
|
+
* `pushd` into a plain directory outside `/dev` and `/proc` keeps the exemption (`cd ~/repo && cat > .env`, #1706
|
|
2613
|
+
* item 6): the word after it is judged, not where the `cd` stands, so a quoted or `eval`-ed `cd /dev` still counts.
|
|
2614
|
+
* Any other word after it (none, `-`, an option, `..`, a quote or `$`), a second operand (zsh `cd old new`), and
|
|
2615
|
+
* every `popd` count as moving.
|
|
2616
|
+
* NAMED CEILING (BV-004): a `cd` written only as text (`git commit -m "cd x"`) with an unplain word after it still
|
|
2617
|
+
* withholds the exemption (fail-closed), and a plain directory that is a symlink to a device is the planted-symlink
|
|
2618
|
+
* ceiling above; revisit with a quote-aware command-position reading if the false alarm shows up in practice.
|
|
2619
|
+
*/
|
|
2620
|
+
function movesTargets(text: string): boolean {
|
|
2621
|
+
if (MOVES_HOME.test(text)) return true
|
|
2622
|
+
for (const [, cmd, dir = '', second] of text.matchAll(MOVES_CWD)) {
|
|
2623
|
+
// The first segment that names a directory: `/./dev` and `./dev` reach `dev` too.
|
|
2624
|
+
const top = dir.replace(/^~\//, '').split('/').find(segment => segment !== '' && segment !== '.') ?? ''
|
|
2625
|
+
if (cmd === 'popd' || second !== undefined || !SAFE_DIR.test(dir) || dir.split('/').includes('..') || DEVICE_ROOTS.has(top.toLowerCase())) return true
|
|
2626
|
+
}
|
|
2627
|
+
return false
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// A run of characters a file name is written with, and the output operator (`>`, `>>`, `>|`) right before it.
|
|
2631
|
+
const NAME_RUN = /(>[|>]?[ \t]*)?([^\s;&|()<>"'`$\\]+)/g
|
|
2632
|
+
|
|
2633
|
+
// Commands that name a file without printing what is in it (`chmod 600 ~/.npmrc` after writing it): a denylist, so
|
|
2634
|
+
// any other command that names the file counts as reading it (fail-closed).
|
|
2635
|
+
// `source` / `.` are not among them: they run the file, and a body line run that way prints (`command not found: …`).
|
|
2636
|
+
const FILE_META: ReadonlySet<string> = new Set(['chmod', 'chown', 'chgrp', 'rm', 'unlink', 'touch', 'test', '[', '[[', 'ls', 'stat'])
|
|
2637
|
+
|
|
2638
|
+
/**
|
|
2639
|
+
* How often each file base name appears in the text other than as an output redirect target (`cat .npmrc`,
|
|
2640
|
+
* `< .npmrc`, `"$HOME/.npmrc"`, an operand of `tee`) outside a {@link FILE_META} command, and the base names of the
|
|
2641
|
+
* output targets. One pass over the segments.
|
|
2642
|
+
*/
|
|
2643
|
+
function fileNames(text: string): { reads: Map<string, number>; targets: string[] } {
|
|
2644
|
+
const reads = new Map<string, number>()
|
|
2645
|
+
const targets: string[] = []
|
|
2646
|
+
for (const segment of splitSegments(text)) {
|
|
2647
|
+
const meta = FILE_META.has(baseName(commandWords(segment)[0] ?? ''))
|
|
2648
|
+
for (const [, op, name = ''] of segment.matchAll(NAME_RUN)) {
|
|
2649
|
+
const base = name.split('/').pop() ?? name
|
|
2650
|
+
if (op !== undefined) targets.push(base)
|
|
2651
|
+
else if (!meta) reads.set(base, (reads.get(base) ?? 0) + 1)
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
return { reads, targets }
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/**
|
|
2658
|
+
* Whether a file the stage writes is named again elsewhere in the text, where another command may print it:
|
|
2659
|
+
* `cat > .npmrc <<EOF … EOF; cat .npmrc` (#1706 item 2). Judged by base name over the whole text, read once.
|
|
2660
|
+
* NAMED CEILING (BV-004): order is not read (a read BEFORE the write withholds too) and a mention in a body or a
|
|
2661
|
+
* quote counts (both fail-closed); a read through another name (`cat ~/.n*`) is not seen. Revisit if either shows up.
|
|
2662
|
+
*/
|
|
2663
|
+
function namedAgain(stage: string, reads: ReadonlyMap<string, number>): boolean {
|
|
2664
|
+
const own = fileNames(stage)
|
|
2665
|
+
const [first, ...args] = commandWords(stage)
|
|
2666
|
+
// `tee` names the files it writes as operands; they count as written, and once among the stage's own names.
|
|
2667
|
+
const operands = baseName(first ?? '') === 'tee' ? args.filter(arg => !arg.startsWith('-')).map(arg => arg.split('/').pop() ?? arg) : []
|
|
2668
|
+
return [...own.targets, ...operands].some(base => (reads.get(base) ?? 0) > (own.reads.get(base) ?? 0))
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/**
|
|
2672
|
+
* A stage that writes what it reads into files only: `cat` (no operand but `-`) or `tee` (its options and plain
|
|
2673
|
+
* file operands) with every fd-1 target a {@link safeFileTarget} or `/dev/null`, and no pipe after it — zsh MULTIOS
|
|
2674
|
+
* writes `cat >o <<EOF | cat` into both. `1<&2` / `>&2` keep fd 1 on a terminal ({@link outputSilence}).
|
|
2675
|
+
*/
|
|
2676
|
+
function writesFilesOnly(stage: string): boolean {
|
|
2677
|
+
if (cutCompound(stage)) return false
|
|
2678
|
+
const [first, ...args] = commandWords(stage)
|
|
2679
|
+
const cmd = baseName(first ?? '')
|
|
2680
|
+
const operands = cmd === 'cat' ? args.every(arg => arg === '-') : cmd === 'tee' && args.every(arg => TEE_OPTIONS.has(arg) || safeFileTarget(arg))
|
|
2681
|
+
return operands && outputSilence(stage, safeFileTarget).stdout
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
/**
|
|
2685
|
+
* Whether the stage a heredoc feeds prints its body: the {@link echoedSecrets} reading of a here-string (a stage
|
|
2686
|
+
* that prints none of its input, or a silent consumer after the pipe, prints nothing), plus the file writes of
|
|
2687
|
+
* {@link writesFilesOnly}. A heredoc inside `$(…)`, a backtick or `<(…)`, a stage the scanner could not delimit,
|
|
2688
|
+
* and a `read` (it fills a variable a later command may print) count as printing — fail-closed. `filesExempt`
|
|
2689
|
+
* false (the text moves the cwd or HOME, {@link movesTargets}) withholds the file exemption, and so does a written
|
|
2690
|
+
* file the text names again ({@link namedAgain}, `reads` from {@link fileNames} over the whole text).
|
|
2691
|
+
*/
|
|
2692
|
+
function heredocPrints({ text: stage, next, nested }: HeredocStage, filesExempt: boolean, reads: ReadonlyMap<string, number>): boolean {
|
|
2693
|
+
if (nested || stage === null || readNames(stage) !== null) return true
|
|
2694
|
+
if (filesExempt && next === undefined && writesFilesOnly(stage) && !namedAgain(stage, reads)) return false
|
|
2695
|
+
if (printsNoInput(stage)) return false
|
|
2696
|
+
const quiet = consumesSilently(stage)
|
|
2697
|
+
if (next === undefined) return !quiet
|
|
2698
|
+
return next === null || !consumesSilently(next) || (outputSilence(stage).moved && !quiet)
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// A `printenv` substitution in a body line and its words up to the closing `)` or backtick.
|
|
2702
|
+
const PRINTENV_SUBST = /(?:\$\(|`)[ \t]*(?:command[ \t]+)?printenv((?:[ \t]+[^\s)`]+)+)/g
|
|
2703
|
+
|
|
2704
|
+
/**
|
|
2705
|
+
* The first secret a heredoc body line expands: a secret-shaped or tainted `$name` / `${name`, or a `printenv` of
|
|
2706
|
+
* one in a substitution, not after an odd run of `\`.
|
|
2707
|
+
*/
|
|
2708
|
+
function bodySecret(line: string, tainted: ReadonlySet<string>): string | undefined {
|
|
2709
|
+
const unescaped = (at: number): boolean => {
|
|
2710
|
+
let slashes = 0
|
|
2711
|
+
for (let j = at - 1; j >= 0 && line[j] === '\\'; j -= 1) slashes += 1
|
|
2712
|
+
return slashes % 2 === 0
|
|
2713
|
+
}
|
|
2714
|
+
const secret = (name: string): boolean => SECRET_VAR.test(`$${name}`) || tainted.has(name)
|
|
2715
|
+
for (const m of line.matchAll(VAR_REF)) {
|
|
2716
|
+
const name = m[1] ?? ''
|
|
2717
|
+
if (secret(name) && unescaped(m.index ?? 0)) return name
|
|
2718
|
+
}
|
|
2719
|
+
// `$(printenv NAME)` / `` `printenv NAME` `` substitutes the value as `echo $(printenv NAME)` prints it (#1706 item 3).
|
|
2720
|
+
for (const m of line.matchAll(PRINTENV_SUBST)) {
|
|
2721
|
+
const name = (m[1] ?? '').split(/\s+/).find(secret)
|
|
2722
|
+
if (name !== undefined && unescaped(m.index ?? 0)) return name
|
|
2723
|
+
}
|
|
2724
|
+
return undefined
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
/**
|
|
2728
|
+
* The SEC-008 trap of a heredoc whose unquoted body expands a secret into a stage that prints it (#1696), read by
|
|
2729
|
+
* {@link scanHeredocs} in one linear pass. A text the scanner cannot read safely (an unbalanced quote, more
|
|
2730
|
+
* heredocs on a line than it queues, an operator without a delimiter) traps only when it names a secret anywhere.
|
|
2731
|
+
*/
|
|
2732
|
+
function heredocTrap(text: string, tainted: ReadonlySet<string>): Trap | null {
|
|
2733
|
+
if (!text.includes('<<') && !text.includes("'") && !text.includes('"')) return null
|
|
2734
|
+
const filesExempt = !movesTargets(text)
|
|
2735
|
+
// Read once, and only when a body with a secret asks for a verdict.
|
|
2736
|
+
let reads: Map<string, number> | undefined
|
|
2737
|
+
const finding = scanHeredocs(text, line => bodySecret(line, tainted), stage => heredocPrints(stage, filesExempt, (reads ??= fileNames(text).reads)))
|
|
2738
|
+
if (finding === null) return null
|
|
2739
|
+
const name = finding.kind === 'body' ? finding.name : (SECRET_VAR.exec(text)?.[1] ?? bodySecret(text, tainted))
|
|
2740
|
+
if (name === undefined) return null
|
|
2741
|
+
return {
|
|
2742
|
+
id: 'sec008-echo-secret',
|
|
2743
|
+
klasse: 'sec008',
|
|
2744
|
+
title: finding.kind === 'body' ? `<<${finding.delimiter} $${name}` : `unlesbare Quote-/Heredoc-Form mit $${name}`,
|
|
2745
|
+
why: finding.kind === 'body'
|
|
2746
|
+
? 'Ein Heredoc mit ungequotetem Delimiter setzt den Wert ein und druckt ihn ins Transkript (SEC-008).'
|
|
2747
|
+
: 'Quotes oder Heredocs sind nicht sicher lesbar, und eine Secret-Variable kommt vor (SEC-008, fail-closed).',
|
|
2748
|
+
fix: `test -n "\${${name}:-}" && echo "${name}: set"; echo "\${#${name}}" # gesetzt? Länge?`,
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// The builtins that print their arguments: echo, printf and zsh's print (#1699).
|
|
2753
|
+
const PRINTERS: ReadonlySet<string> = new Set(['echo', 'printf', 'print'])
|
|
2754
|
+
// A function definition head as one word: `f()`, `f(){` (#1699).
|
|
2755
|
+
const FUNCTION_HEAD = /^[\w.:-]+\(\)\{?$/
|
|
2756
|
+
|
|
2757
|
+
/**
|
|
2758
|
+
* The command a stage runs, read past `builtin` (`builtin echo`) and past a function definition head (`f() {`,
|
|
2759
|
+
* `f () (`, `function f {`): a printer in a function body is reported where it is defined, whether or not the
|
|
2760
|
+
* function is called later (#1699) — simpler than following calls, and a defined body is meant to run.
|
|
2761
|
+
* NAMED CEILING (BV-004): a redirect after the closing brace (`f() { echo "$T"; } >/dev/null`) sits in another stage
|
|
2762
|
+
* and is not seen, so that silenced body still reports; revisit if such a definition shows up in practice.
|
|
2763
|
+
*/
|
|
2764
|
+
function printerCommand(stage: string): string {
|
|
2765
|
+
const words = commandWords(stage)
|
|
2766
|
+
let at = 0
|
|
2767
|
+
if (words[0] === 'function') at = words[2] === '()' ? 3 : 2
|
|
2768
|
+
else if (FUNCTION_HEAD.test(words[0] ?? '')) at = 1
|
|
2769
|
+
else if (words[1] === '()') at = 2
|
|
2770
|
+
if (at > 0 && (words[at] === '{' || words[at] === '(')) at += 1
|
|
2771
|
+
if (words[at] === 'builtin') at += 1
|
|
2772
|
+
return baseName(words[at] ?? '')
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
/**
|
|
2776
|
+
* Adds to `tainted` the names a stage assigns a secret or tainted value (`x=$T_TOKEN`, `y=$x`, `export x="$T"`),
|
|
2777
|
+
* so a later `echo "$x"` reads as printing it (#1699), like a name a `read` filled. Only assignments the shell keeps:
|
|
2778
|
+
* a stage of nothing but assignments, or the operands of a declaring builtin — `x=$T cmd` sets x for cmd's
|
|
2779
|
+
* environment only. NAMED CEILING (BV-004): a value that reaches the secret through a command (`x=$(printenv T)`)
|
|
2780
|
+
* or an expansion operator (`: ${x:=$T}`) is not followed, and a single-quoted `x='$T'` taints too (fail-closed);
|
|
2781
|
+
* revisit if one of them shows up in practice.
|
|
2782
|
+
*/
|
|
2783
|
+
function assignedSecrets(stage: string, tainted: Set<string>): void {
|
|
2784
|
+
const words = rawWords(stripGroup(stage))
|
|
2785
|
+
const declarer = DECLARERS.has(words[0] ?? '')
|
|
2786
|
+
if (!declarer && !words.every(word => ASSIGNMENT.test(word))) return
|
|
2787
|
+
for (const word of declarer ? words.slice(1) : words) {
|
|
2788
|
+
const name = ASSIGNMENT.exec(word)?.[1]
|
|
2789
|
+
if (name !== undefined && secretRef(word.slice(word.indexOf('=') + 1), tainted) !== undefined) tainted.add(name)
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
/**
|
|
2794
|
+
* The stages of one pipeline that print a secret: an echoed secret, or one fed as a here-string (`cat <<< "$TOKEN"`),
|
|
2795
|
+
* reaches the transcript unless the stage itself discards it or the stage directly after consumes it silently (an
|
|
2796
|
+
* allow list: anything else re-prints). With a pipe after it the stage writes into the pipe even when its fd 1 is
|
|
2797
|
+
* redirected (zsh MULTIOS: `echo "$TOKEN" >/dev/null | cat` prints, #1696), and the redirect target receives it too
|
|
2798
|
+
* (`echo "$TOKEN" >&2 | sha256sum` prints it on stderr, #1649) — both shells' readings are judged.
|
|
2799
|
+
* `tainted` carries the variables a `read` filled with a secret across the pipelines of the line: zsh runs the last
|
|
2800
|
+
* stage in the current shell, so `echo "$TOKEN" | read -r x; echo "$x"` prints it (#1695); a here-string `read`
|
|
2801
|
+
* keeps it in every shell. A `read` in an earlier stage runs in a subshell and sets nothing here.
|
|
2802
|
+
*/
|
|
2803
|
+
function echoedSecrets(stages: readonly string[], tainted: Set<string>): Trap[] {
|
|
2804
|
+
const found: Trap[] = []
|
|
2805
|
+
let carries = false
|
|
2806
|
+
for (let k = 0; k < stages.length; k += 1) {
|
|
2807
|
+
const stage = stages[k] ?? ''
|
|
2808
|
+
assignedSecrets(stage, tainted)
|
|
2809
|
+
const stageCmd = printerCommand(stage)
|
|
2810
|
+
const echoed = PRINTERS.has(stageCmd) ? secretRef(stage, tainted) : undefined
|
|
2811
|
+
const hereString = stage.indexOf('<<<')
|
|
2812
|
+
const fed = hereString === -1 ? undefined : secretRef(stage.slice(hereString), tainted)
|
|
2813
|
+
// Fail-closed: any earlier stage that names a secret may feed it to a `read` at the end, redirected or not (MULTIOS).
|
|
2814
|
+
if (k === stages.length - 1 && (carries || fed !== undefined)) for (const name of readNames(stage) ?? []) tainted.add(name)
|
|
2815
|
+
carries ||= secretRef(stage, tainted) !== undefined
|
|
2816
|
+
const name = echoed ?? fed
|
|
2817
|
+
if (name === undefined || (echoed === undefined && printsNoInput(stage))) continue
|
|
2818
|
+
// echo writes nothing to stderr, so its stdout alone decides; bash's printf prints a bad numeric argument
|
|
2819
|
+
// (`printf %d "$TOKEN"`) on stderr and zsh's `print -u2` writes there, so both need both.
|
|
2820
|
+
const silence = outputSilence(stage)
|
|
2821
|
+
const quiet = echoed !== undefined ? silence.stdout && (stageCmd === 'echo' || silence.stderr) : consumesSilently(stage)
|
|
2822
|
+
const piped = k + 1 < stages.length
|
|
2823
|
+
const printed = piped ? !consumesSilently(stages[k + 1] ?? '') || (silence.moved && !quiet) : !quiet
|
|
2824
|
+
if (printed) {
|
|
2825
|
+
found.push({
|
|
2826
|
+
id: 'sec008-echo-secret',
|
|
2827
|
+
klasse: 'sec008',
|
|
2828
|
+
title: echoed !== undefined ? `echo $${name}` : `<<< $${name}`,
|
|
2829
|
+
why: 'Druckt den Wert ins Transkript (SEC-008). Prüfen geht ohne Ausgabe.',
|
|
2830
|
+
fix: `test -n "\${${name}:-}" && echo "${name}: set"; echo "\${#${name}}" # gesetzt? Länge?`,
|
|
2831
|
+
})
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
return found
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
export type Profil = 'locker' | 'ausgewogen' | 'streng'
|
|
2838
|
+
|
|
2839
|
+
export type Verdict = { aktion: 'laufen' } | { aktion: 'warnen'; text: string } | { aktion: 'ablehnen'; text: string }
|
|
2840
|
+
|
|
2841
|
+
/**
|
|
2842
|
+
* What the gate does with the traps of one command. A SEC-008 trap refuses on every profile; rule
|
|
2843
|
+
* traps only warn on `locker` and refuse otherwise. Any profile not exactly `locker` refuses (fail-closed).
|
|
2844
|
+
*/
|
|
2845
|
+
export function trapVerdict(traps: readonly Trap[], profil: Profil): Verdict {
|
|
2846
|
+
if (traps.length === 0) return { aktion: 'laufen' }
|
|
2847
|
+
const text = traps.map(trap => `[so-hud · Regel-Falle ${trap.id}] ${trap.why}\nStattdessen: ${trap.fix}`).join('\n')
|
|
2848
|
+
return profil === 'locker' && traps.every(trap => trap.klasse === 'regel') ? { aktion: 'warnen', text } : { aktion: 'ablehnen', text }
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
// ---- Collisions with a peer's wave scope (F8) --------------------------------
|
|
2852
|
+
|
|
2853
|
+
/**
|
|
2854
|
+
* Whether a path matches one SO wave-scope entry, with the semantics of SO's own
|
|
2855
|
+
* `pathMatchesPattern` (scripts/lib/scope-gate.mjs), mirrored because a mod cannot import it:
|
|
2856
|
+
* exact path; a trailing `/` is a directory prefix; `*` stays within one segment; `**` spans any
|
|
2857
|
+
* depth, `**\/` also zero segments. Case-sensitive; an empty pattern never matches.
|
|
2858
|
+
*/
|
|
2859
|
+
export function scopeMatches(relPath: string, pattern: string): boolean {
|
|
2860
|
+
if (!pattern) return false
|
|
2861
|
+
if (pattern.endsWith('/')) return relPath.startsWith(pattern)
|
|
2862
|
+
const regex = pattern
|
|
2863
|
+
.replace(/[.+?|[\](){}\\^$]/g, ch => `\\${ch}`)
|
|
2864
|
+
.replace(/\*\*\//g, '<<DBLS>>')
|
|
2865
|
+
.replace(/\*\*/g, '<<DBLG>>')
|
|
2866
|
+
.replace(/\*/g, '[^/]*')
|
|
2867
|
+
.replace(/<<DBLS>>/g, '(.*\\/)?')
|
|
2868
|
+
.replace(/<<DBLG>>/g, '.*')
|
|
2869
|
+
return new RegExp(`^${regex}$`).test(relPath)
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
/** An absolute path with `.`, `..` and repeated slashes resolved, by string operations only. */
|
|
2873
|
+
function normalizeAbsolute(path: string): string {
|
|
2874
|
+
const parts: string[] = []
|
|
2875
|
+
for (const part of path.split('/')) {
|
|
2876
|
+
if (part === '' || part === '.') continue
|
|
2877
|
+
if (part === '..') parts.pop()
|
|
2878
|
+
else parts.push(part)
|
|
2879
|
+
}
|
|
2880
|
+
return `/${parts.join('/')}`
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
export type ScopePeer = { session_id: string; gleiches_working_copy: boolean; scope_paths: readonly string[] | null }
|
|
2884
|
+
export type Collision = { session_id: string; pattern: string }
|
|
2885
|
+
|
|
2886
|
+
/**
|
|
2887
|
+
* Which live peers in this working copy hold the file in their wave scope. Peers in another working
|
|
2888
|
+
* copy and this session itself never collide; a peer without a readable scope is listed in
|
|
2889
|
+
* `unknownScope` instead (it can neither be proven disjoint nor shown to collide). A file outside
|
|
2890
|
+
* `repoRoot` lies in no peer's working copy: no hits and no unknown scopes. Absolute scope entries
|
|
2891
|
+
* are matched against the absolute path, all others against the repo-relative one.
|
|
2892
|
+
*/
|
|
2893
|
+
export function findCollisions(input: {
|
|
2894
|
+
filePath: string
|
|
2895
|
+
repoRoot: string
|
|
2896
|
+
ownSessionId: string | null
|
|
2897
|
+
peers: readonly ScopePeer[]
|
|
2898
|
+
}): { hits: Collision[]; unknownScope: string[] } {
|
|
2899
|
+
const hits: Collision[] = []
|
|
2900
|
+
const unknownScope: string[] = []
|
|
2901
|
+
const root = normalizeAbsolute(input.repoRoot)
|
|
2902
|
+
const file = normalizeAbsolute(input.filePath)
|
|
2903
|
+
if (root === '/' || !file.startsWith(`${root}/`)) return { hits, unknownScope }
|
|
2904
|
+
const rel = file.slice(root.length + 1)
|
|
2905
|
+
for (const peer of input.peers) {
|
|
2906
|
+
if (peer.gleiches_working_copy !== true || peer.session_id === input.ownSessionId) continue
|
|
2907
|
+
if (peer.scope_paths === null) {
|
|
2908
|
+
unknownScope.push(peer.session_id)
|
|
2909
|
+
continue
|
|
2910
|
+
}
|
|
2911
|
+
for (const pattern of peer.scope_paths) {
|
|
2912
|
+
if (scopeMatches(pattern.startsWith('/') ? file : rel, pattern)) hits.push({ session_id: peer.session_id, pattern })
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
return { hits, unknownScope }
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
export const COLLISION_OPTIONS = ['Trotzdem', 'Worktree vorschlagen', 'Abbrechen'] as const
|
|
2919
|
+
|
|
2920
|
+
export type CollisionVerdict =
|
|
2921
|
+
| { aktion: 'laufen' }
|
|
2922
|
+
| { aktion: 'warnen'; text: string }
|
|
2923
|
+
| { aktion: 'fragen'; text: string; optionen: typeof COLLISION_OPTIONS }
|
|
2924
|
+
| { aktion: 'ablehnen'; text: string }
|
|
2925
|
+
|
|
2926
|
+
/** What the collision guard does per profile: locker warns, ausgewogen asks, streng (and any unknown profile) refuses. */
|
|
2927
|
+
export function collisionVerdict(hits: readonly Collision[], profil: Profil): CollisionVerdict {
|
|
2928
|
+
if (hits.length === 0) return { aktion: 'laufen' }
|
|
2929
|
+
const text = hits.map(hit => `[so-hud · Kollision] Peer-Session ${hit.session_id} hat ${hit.pattern} im Wellen-Scope.`).join('\n')
|
|
2930
|
+
if (profil === 'locker') return { aktion: 'warnen', text }
|
|
2931
|
+
if (profil === 'ausgewogen') return { aktion: 'fragen', text, optionen: COLLISION_OPTIONS }
|
|
2932
|
+
return { aktion: 'ablehnen', text: `${text}\nStattdessen: in einem eigenen Worktree weiterarbeiten (git worktree add <pfad> -b <branch>).` }
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
// ---- Secrets in output ------------------------------------------------------
|
|
2936
|
+
|
|
2937
|
+
export type SecretHit = { name: string; fp: string }
|
|
2938
|
+
|
|
2939
|
+
export const SECRET_PATTERNS: readonly { name: string; re: RegExp }[] = [
|
|
2940
|
+
{ name: 'Private Key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
2941
|
+
{ name: 'GitLab-Token', re: /\bgl(?:pat|dt|rt|ptt|cbt|soat|ffct|imt|oas|agent)-[A-Za-z0-9_-]{20,}/g },
|
|
2942
|
+
{ name: 'GitHub-Token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g },
|
|
2943
|
+
{ name: 'Anthropic-Key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
2944
|
+
{ name: 'OpenAI-Key', re: /\bsk-(?:proj-|svcacct-)?(?!ant-)[A-Za-z0-9_-]{32,}/g },
|
|
2945
|
+
{ name: 'Stripe-Key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g },
|
|
2946
|
+
{ name: 'Stripe-Webhook-Secret', re: /\bwhsec_[A-Za-z0-9]{24,}/g },
|
|
2947
|
+
{ name: 'AWS-Access-Key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
2948
|
+
{ name: 'Slack-Token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
2949
|
+
{ name: 'Google-API-Key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
2950
|
+
]
|
|
2951
|
+
|
|
2952
|
+
export function hasSecret(text: string): boolean {
|
|
2953
|
+
return SECRET_PATTERNS.some(({ re }) => (text.match(re)?.length ?? 0) > 0)
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
// The mod's lib is es2023 without DOM types (tsconfig `types: []`), yet Web Crypto and TextEncoder
|
|
2957
|
+
// are globals in both the mod engine and Node: read them off globalThis in the one shape used here.
|
|
2958
|
+
type WebGlobals = {
|
|
2959
|
+
crypto: { subtle: { digest(algorithm: string, data: Uint8Array): Promise<ArrayBuffer> } }
|
|
2960
|
+
TextEncoder: new () => { encode(input: string): Uint8Array }
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
async function fingerprint(secret: string): Promise<string> {
|
|
2964
|
+
const { crypto, TextEncoder } = globalThis as unknown as WebGlobals
|
|
2965
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
|
|
2966
|
+
return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 12)
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
/** The text with every secret replaced by its kind and a sha256 fingerprint. */
|
|
2970
|
+
export async function redactText(text: string, hits: SecretHit[]): Promise<string> {
|
|
2971
|
+
let out = text
|
|
2972
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
2973
|
+
for (const secret of new Set(out.match(re) ?? [])) {
|
|
2974
|
+
const fp = await fingerprint(secret)
|
|
2975
|
+
if (!hits.some(hit => hit.fp === fp)) hits.push({ name, fp })
|
|
2976
|
+
out = out.split(secret).join(`[${name} entfernt · sha256:${fp}]`)
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
return out
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
/** A copy of any JSON-like value with every string redacted. */
|
|
2983
|
+
export async function redactDeep(value: unknown, hits: SecretHit[]): Promise<unknown> {
|
|
2984
|
+
if (typeof value === 'string') {
|
|
2985
|
+
return hasSecret(value) ? redactText(value, hits) : value
|
|
2986
|
+
}
|
|
2987
|
+
if (Array.isArray(value)) {
|
|
2988
|
+
const out: unknown[] = []
|
|
2989
|
+
for (const item of value) out.push(await redactDeep(item, hits))
|
|
2990
|
+
return out
|
|
2991
|
+
}
|
|
2992
|
+
if (value !== null && typeof value === 'object') {
|
|
2993
|
+
const out: Record<string, unknown> = {}
|
|
2994
|
+
for (const [key, item] of Object.entries(value)) out[key] = await redactDeep(item, hits)
|
|
2995
|
+
return out
|
|
2996
|
+
}
|
|
2997
|
+
return value
|
|
2998
|
+
}
|