session-orchestrator 5.12.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 +2 -2
- 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 +162 -4
- package/README.md +126 -127
- 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 +1 -1
- 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 +90 -17
- 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 +49 -15
- package/docs/session-config-template.md +30 -22
- 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 +139 -49
- 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 -2
- 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 +39 -27
- 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/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 +2 -2
- 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 +4 -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-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +72 -9
- 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 +49 -5
- package/scripts/lib/so-hud/runtime.mjs +15 -4
- 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 +14 -5
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- 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 +19 -7
- package/scripts/lib/vault-mirror/process.mjs +56 -27
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
- 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 +79 -0
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +2 -1
- 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 +10 -17
- 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 +29 -6
- package/scripts/state-md-idle-reset.mjs +2 -1
- 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/skills/_shared/fleet-protocol.md +142 -1
- 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 +3 -2
- package/skills/architecture/references/domain-model.md +1 -1
- 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 +94 -11
- 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-documentation-updates.md +14 -12
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +4 -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 +9 -3
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- 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 +2 -2
- 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/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -74,6 +74,12 @@
|
|
|
74
74
|
* hook's protection scope is shell-redirect writes that BYPASS validation,
|
|
75
75
|
* and this command never does that.
|
|
76
76
|
*
|
|
77
|
+
* #1577/#1656 widen this by a CLOSED grammar, not a pattern: the same command
|
|
78
|
+
* with exactly one explicit `--repo-root <ABS>` (either side of `--apply`),
|
|
79
|
+
* and the same two shapes with this plugin's own absolute script path. The
|
|
80
|
+
* root is judged on its own (Git top-level, ledger present, not the Vault) —
|
|
81
|
+
* see `allowsRepairApply`. Every other deviation still falls through to DENY.
|
|
82
|
+
*
|
|
77
83
|
* ## Path scope
|
|
78
84
|
*
|
|
79
85
|
* A write target counts as the ledger when its BASENAME is `sessions.jsonl`,
|
|
@@ -232,13 +238,15 @@
|
|
|
232
238
|
* denied (`"$PWD"/…/sessions.jsonl`, `$HOME/…/sessions.jsonl`,
|
|
233
239
|
* `"$(pwd)/…/sessions.jsonl"` and `exec 3> …/sessions.jsonl` all deny;
|
|
234
240
|
* verified, not assumed).
|
|
235
|
-
* -
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
241
|
+
* - Since #1665 a `$(…)` / backtick substitution — quoted or not, nested
|
|
242
|
+
* double quotes included — is parsed as its own command line, so
|
|
243
|
+
* `echo "$(echo x >> …/sessions.jsonl)"` and
|
|
244
|
+
* `echo "$(node -e "…appendFileSync('…/sessions.jsonl',…)")"` deny. Three
|
|
245
|
+
* substitution shapes stay out: a `case` pattern's `)` inside a body ends
|
|
246
|
+
* it early; a body nested past MAX_PAYLOAD_DEPTH is marked, not judged;
|
|
247
|
+
* and a double-quoted body this lexer cannot parse is allowed with an
|
|
248
|
+
* `unparsed-substitution` notice instead of failing closed (a real
|
|
249
|
+
* `printf '… "x" …'` description denied before that rule).
|
|
242
250
|
* - In-place editors: `sed -i`, `perl -i`, `ed`, an interactive editor.
|
|
243
251
|
* - A write performed by a SCRIPT FILE the command invokes
|
|
244
252
|
* (`node tools/append.mjs`) — the ledger name never appears in the command
|
|
@@ -272,10 +280,11 @@
|
|
|
272
280
|
* guarded by `hooks/pre-edit-sessions-ledger-guard.mjs` (#1443), not this
|
|
273
281
|
* hook's surface.
|
|
274
282
|
* - The #385 repair-apply subshell/backtick deny (`unwrapSubshellLayers`)
|
|
275
|
-
* only unwraps a command that IS, in its entirety, one wrapped run
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
283
|
+
* only unwraps a command that IS, in its entirety, one wrapped run. A
|
|
284
|
+
* wrapper inside a larger command —
|
|
285
|
+
* `x=$(node scripts/repair-invalid-sessions.mjs --apply)` — is no longer a
|
|
286
|
+
* gap since #1665: the substitution body reaches the general matcher and
|
|
287
|
+
* denies there.
|
|
279
288
|
*
|
|
280
289
|
* This is a guard against the accident that actually happened, not a
|
|
281
290
|
* containment boundary. Determined circumvention is out of scope by design.
|
|
@@ -310,6 +319,7 @@
|
|
|
310
319
|
*/
|
|
311
320
|
|
|
312
321
|
import { spawnSync } from 'node:child_process';
|
|
322
|
+
import { lstatSync, realpathSync } from 'node:fs';
|
|
313
323
|
import path from 'node:path';
|
|
314
324
|
import { pathToFileURL } from 'node:url';
|
|
315
325
|
|
|
@@ -451,6 +461,17 @@ async function bootstrap() {
|
|
|
451
461
|
/** The ledger's filename. A write target matches on basename equality. */
|
|
452
462
|
const LEDGER_BASENAME = 'sessions.jsonl';
|
|
453
463
|
|
|
464
|
+
/**
|
|
465
|
+
* What a `$( … )` / backtick span becomes in `sanitized` (#1665) and in a
|
|
466
|
+
* redirect word's `skeleton` (#1671): a visible `_` plus an invisible U+2063,
|
|
467
|
+
* so a literal `_` in a file name is never mistaken for a substitution and
|
|
468
|
+
* {@link mayNameLedgerDynamically} can treat exactly the spans as wildcards.
|
|
469
|
+
*/
|
|
470
|
+
const SUBST_PLACEHOLDER = '_\u2063';
|
|
471
|
+
|
|
472
|
+
/** The ledger's directory, repo-relative (#1671 dynamic-name rule). */
|
|
473
|
+
const LEDGER_DIR = '.orchestrator/metrics';
|
|
474
|
+
|
|
454
475
|
/** The repair CLI whose effective apply mode mutates the sessions ledger internally. */
|
|
455
476
|
const REPAIR_SCRIPT_BASENAME = 'repair-invalid-sessions.mjs';
|
|
456
477
|
const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
|
|
@@ -478,6 +499,211 @@ const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
|
|
|
478
499
|
*/
|
|
479
500
|
const REPAIR_APPLY_CANONICAL_COMMAND = `node scripts/${REPAIR_SCRIPT_BASENAME} --apply`;
|
|
480
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Every verb that IS the Node runtime (#1665). Debian/Ubuntu ship the binary as
|
|
504
|
+
* `nodejs`, and a guard that recognised only `node` let
|
|
505
|
+
* `nodejs scripts/repair-invalid-sessions.mjs --apply …` through. One set, read
|
|
506
|
+
* by every place that asks "is this verb Node?" — the eval-payload family table
|
|
507
|
+
* (`INTERPRETER_FAMILY`) carries the same two names.
|
|
508
|
+
*/
|
|
509
|
+
const NODE_VERBS = new Set(['node', 'nodejs']);
|
|
510
|
+
// Verbs that only print or search their operands — they never execute a script
|
|
511
|
+
// they name, so the non-node repair-runner rule (#1670) does not apply to them.
|
|
512
|
+
const TEXT_ONLY_VERBS = new Set(['echo', 'printf', 'grep', 'egrep', 'fgrep', 'rg', 'cat', 'head', 'tail', 'less', 'wc']);
|
|
513
|
+
|
|
514
|
+
/** This plugin's own repair script — the only sanctioned absolute operand (#1577). */
|
|
515
|
+
const REPAIR_ABSOLUTE_SCRIPT = path.join(PLUGIN_ROOT, 'scripts', REPAIR_SCRIPT_BASENAME);
|
|
516
|
+
|
|
517
|
+
/** Deny-message hint: every form the closed allowlist accepts (#385, #1577, #1656). */
|
|
518
|
+
const REPAIR_SANCTIONED_FORMS = [
|
|
519
|
+
` ${REPAIR_APPLY_CANONICAL_COMMAND}`,
|
|
520
|
+
` ${REPAIR_APPLY_CANONICAL_COMMAND} --repo-root <absolute-git-toplevel>`,
|
|
521
|
+
` node ${REPAIR_ABSOLUTE_SCRIPT} --apply --repo-root <absolute-git-toplevel>`,
|
|
522
|
+
` (--repo-root <path> may also precede --apply)`,
|
|
523
|
+
].join('\n');
|
|
524
|
+
|
|
525
|
+
/** A canonical, existing path with no symlink component (including parents). */
|
|
526
|
+
function isSymlinkFreeAbsolute(candidate) {
|
|
527
|
+
if (typeof candidate !== 'string' || !path.isAbsolute(candidate) || path.resolve(candidate) !== candidate) return false;
|
|
528
|
+
try {
|
|
529
|
+
for (let current = candidate; ; current = path.dirname(current)) {
|
|
530
|
+
if (lstatSync(current).isSymbolicLink()) return false;
|
|
531
|
+
if (path.dirname(current) === current) break;
|
|
532
|
+
}
|
|
533
|
+
return realpathSync.native(candidate) === candidate;
|
|
534
|
+
} catch {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Measurement failure never authorizes the new repair exception. */
|
|
540
|
+
function measuredGitRoot(cwd) {
|
|
541
|
+
if (!isSymlinkFreeAbsolute(cwd)) return null;
|
|
542
|
+
if (Object.keys(process.env).some((name) =>
|
|
543
|
+
/^(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_CONFIG.*|GIT_CEILING_DIRECTORIES|GIT_DISCOVERY_ACROSS_FILESYSTEM|GIT_INDEX_FILE|GIT_OBJECT_DIRECTORY|GIT_ALTERNATE_OBJECT_DIRECTORIES)$/.test(name))) return null;
|
|
544
|
+
try {
|
|
545
|
+
const result = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
|
|
546
|
+
encoding: 'utf8', timeout: 2000, maxBuffer: 64 * 1024,
|
|
547
|
+
});
|
|
548
|
+
if (result.error || result.status !== 0) return null;
|
|
549
|
+
const root = result.stdout?.trim();
|
|
550
|
+
if (typeof root !== 'string' || !isSymlinkFreeAbsolute(root)) return null;
|
|
551
|
+
// Local core.worktree can redirect Git's reported top-level away from cwd.
|
|
552
|
+
// A measured root must physically contain this invocation, not merely be
|
|
553
|
+
// the same redirected answer for both process and payload probes (#1577).
|
|
554
|
+
const relative = path.relative(root, cwd);
|
|
555
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) return null;
|
|
556
|
+
const marker = lstatSync(path.join(root, '.git'));
|
|
557
|
+
return !marker.isSymbolicLink() && (marker.isDirectory() || marker.isFile()) ? root : null;
|
|
558
|
+
} catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Conservative charset for an explicit `--repo-root` path (#1577/#1656). */
|
|
564
|
+
const REPAIR_ROOT_CHARSET = /^[A-Za-z0-9._/@+-]+$/;
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Parse the closed repair-apply grammar (#1577/#1656):
|
|
568
|
+
*
|
|
569
|
+
* node <script> --apply
|
|
570
|
+
* node <script> --apply --repo-root <ABS>
|
|
571
|
+
* node <script> --repo-root <ABS> --apply
|
|
572
|
+
*
|
|
573
|
+
* Operands may be wrapped in one pair of simple quotes (a plugin root with a
|
|
574
|
+
* space needs it); shell evaluation characters anywhere in the command fail
|
|
575
|
+
* the parse, so double quotes cannot expand anything. Anything else — an
|
|
576
|
+
* extra argument, `--repo-root=X`, a second `--repo-root`, chaining, a
|
|
577
|
+
* wrapper, a trailing newline — is not this grammar.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} command - the raw, untrimmed command
|
|
580
|
+
* @returns {{ script: string, rawScript: string, root: string|null }|null}
|
|
581
|
+
*/
|
|
582
|
+
function parseRepairApply(command) {
|
|
583
|
+
if (/[$`\\;|&<>()*?[\]{}~!#\r\n]/.test(command)) return null;
|
|
584
|
+
const op = `("[^"]+"|'[^']+'|[^\\s'"]+)`;
|
|
585
|
+
const sep = '[ \\t]+';
|
|
586
|
+
const unquote = (value) => /^["']/.test(value) ? value.slice(1, -1) : value;
|
|
587
|
+
const shapes = [
|
|
588
|
+
{ re: new RegExp(`^node${sep}${op}${sep}--apply$`), root: null },
|
|
589
|
+
{ re: new RegExp(`^node${sep}${op}${sep}--apply${sep}--repo-root${sep}${op}$`), root: 2 },
|
|
590
|
+
{ re: new RegExp(`^node${sep}${op}${sep}--repo-root${sep}${op}${sep}--apply$`), root: 2 },
|
|
591
|
+
];
|
|
592
|
+
for (const shape of shapes) {
|
|
593
|
+
const match = shape.re.exec(command);
|
|
594
|
+
if (match) {
|
|
595
|
+
return {
|
|
596
|
+
rawScript: match[1],
|
|
597
|
+
script: unquote(match[1]),
|
|
598
|
+
root: shape.root === null ? null : unquote(match[shape.root]),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Closed allowlist for the repair CLI beyond the bare #385 exact string
|
|
607
|
+
* (#1577/#1656). The script is either the literal relative
|
|
608
|
+
* `scripts/repair-invalid-sessions.mjs` or this hook's OWN plugin copy
|
|
609
|
+
* (`PLUGIN_ROOT` comes from `import.meta.dirname`, which Node has already
|
|
610
|
+
* realpath'd — env vars are never consulted for it); any other absolute path
|
|
611
|
+
* to a file of that name, including a symlink to the real one, is DENY.
|
|
612
|
+
*
|
|
613
|
+
* Exactly one explicit `--repo-root <ABS>` is required here, and it is the
|
|
614
|
+
* whole target identity: the CLI repairs that root and nothing else, so the
|
|
615
|
+
* hook judges the root itself instead of demanding that hook cwd, payload cwd
|
|
616
|
+
* and the CLI's default project dir agree. That agreement is what made the
|
|
617
|
+
* repair unreachable from a consumer repo or a worktree (`getProjectDir()`
|
|
618
|
+
* names the main checkout there, possibly on a foreign branch — #1656).
|
|
619
|
+
* ABS must be in the conservative charset, absolute, canonical (no `..`, no
|
|
620
|
+
* trailing slash, no symlink component), an existing directory that Git
|
|
621
|
+
* itself reports as its own top-level, hold a regular-file
|
|
622
|
+
* `.orchestrator/metrics/sessions.jsonl`, and lie outside the Vault.
|
|
623
|
+
*
|
|
624
|
+
* Repo modules load only inside this caught path: a broken optional resolver
|
|
625
|
+
* must DENY this exception, not disarm the rest of the ledger guard (#993).
|
|
626
|
+
*
|
|
627
|
+
* @param {string} command - the raw, untrimmed command
|
|
628
|
+
* @returns {Promise<boolean>}
|
|
629
|
+
*/
|
|
630
|
+
async function allowsRepairApply(command) {
|
|
631
|
+
try {
|
|
632
|
+
const parsed = parseRepairApply(command);
|
|
633
|
+
if (!parsed || parsed.root === null) return false;
|
|
634
|
+
const relative = parsed.rawScript === `scripts/${REPAIR_SCRIPT_BASENAME}`;
|
|
635
|
+
if (!relative) {
|
|
636
|
+
const script = parsed.script;
|
|
637
|
+
if (script !== REPAIR_ABSOLUTE_SCRIPT ||
|
|
638
|
+
!isSymlinkFreeAbsolute(script) || !lstatSync(script).isFile()) return false;
|
|
639
|
+
}
|
|
640
|
+
const root = parsed.root;
|
|
641
|
+
if (!REPAIR_ROOT_CHARSET.test(root) || root.split('/').includes('..')) return false;
|
|
642
|
+
if (!isSymlinkFreeAbsolute(root) || !lstatSync(root).isDirectory()) return false;
|
|
643
|
+
if (measuredGitRoot(root) !== root) return false;
|
|
644
|
+
// The target must already carry a regular-file ledger (#1577): a repo
|
|
645
|
+
// without one is no repair target. EVERY component of the ledger path must
|
|
646
|
+
// be symlink-free, not only the root: a scratch repo whose `.orchestrator/`
|
|
647
|
+
// or `metrics/` links into the Vault would otherwise pass the Vault check
|
|
648
|
+
// on the plain root while the script (realpathSync) repairs the Vault.
|
|
649
|
+
const ledger = path.join(root, '.orchestrator', 'metrics', LEDGER_BASENAME);
|
|
650
|
+
if (!isSymlinkFreeAbsolute(ledger) || !lstatSync(ledger).isFile()) return false;
|
|
651
|
+
return !(await isVaultRepairRoot(root));
|
|
652
|
+
} catch {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Vault resolution failure has the Lead-authorized narrow fallback, rather
|
|
659
|
+
* than disabling all absolute repairs. Keep optional config imports here so
|
|
660
|
+
* they cannot disarm the ledger matcher or the relative exact exception.
|
|
661
|
+
*/
|
|
662
|
+
async function isVaultRepairRoot(root) {
|
|
663
|
+
let vault;
|
|
664
|
+
try {
|
|
665
|
+
const { resolveVaultDir, readConfigFile } = await import('../scripts/lib/config.mjs');
|
|
666
|
+
const { loadHostPaths } = await import('../scripts/lib/config/host-paths.mjs');
|
|
667
|
+
const { _parseVaultIntegration } = await import('../scripts/lib/config/vault-integration.mjs');
|
|
668
|
+
const { expandTilde } = await import('../scripts/lib/common.mjs');
|
|
669
|
+
let resolved;
|
|
670
|
+
// An authoritative environment override does not need owner/committed IO.
|
|
671
|
+
if (typeof process.env.SO_VAULT_DIR === 'string' && process.env.SO_VAULT_DIR.trim()) {
|
|
672
|
+
resolved = resolveVaultDir(null);
|
|
673
|
+
} else {
|
|
674
|
+
const { loadOwnerConfig, validateOwnerSections } = await import('../scripts/lib/owner-yaml.mjs');
|
|
675
|
+
const owner = loadOwnerConfig();
|
|
676
|
+
const host = loadHostPaths({ownerLoader: () => owner});
|
|
677
|
+
if (!host.source || host.reason || !validateOwnerSections(host.ownerConfig).sections['vault-dirs'].valid) {
|
|
678
|
+
throw new Error('Vault owner resolution unavailable');
|
|
679
|
+
}
|
|
680
|
+
resolved = resolveVaultDir(null, host);
|
|
681
|
+
if (resolved.source !== 'match' && host.droppedSections?.some(({ section }) => section === 'paths')) {
|
|
682
|
+
throw new Error('Vault owner paths unavailable');
|
|
683
|
+
}
|
|
684
|
+
if (resolved.source === 'committed') {
|
|
685
|
+
if (host.source === 'defaults' && owner.errors?.length) throw new Error('Vault owner defaults are not measurable');
|
|
686
|
+
const committed = _parseVaultIntegration(await readConfigFile(root))['vault-dir'];
|
|
687
|
+
resolved = resolveVaultDir(committed, host);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (typeof resolved.value !== 'string' || !resolved.value.trim()) throw new Error('Vault path unavailable');
|
|
691
|
+
vault = realpathSync.native(expandTilde(resolved.value));
|
|
692
|
+
} catch {
|
|
693
|
+
try {
|
|
694
|
+
// Only HOME/Projects/vault is the fallback; similarly named or nested
|
|
695
|
+
// vault segments elsewhere under Projects are ordinary repair targets.
|
|
696
|
+
if (typeof process.env.HOME !== 'string' || !process.env.HOME.trim()) return false;
|
|
697
|
+
const home = realpathSync.native(process.env.HOME);
|
|
698
|
+
vault = realpathSync.native(path.join(home, 'Projects', 'vault'));
|
|
699
|
+
} catch {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const relative = path.relative(vault, root);
|
|
704
|
+
return relative === '' || (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative));
|
|
705
|
+
}
|
|
706
|
+
|
|
481
707
|
/**
|
|
482
708
|
* How much of the offending target may appear in the deny reason.
|
|
483
709
|
*
|
|
@@ -502,6 +728,14 @@ const DEST_LAST_VERBS = new Set(['cp', 'mv']);
|
|
|
502
728
|
*/
|
|
503
729
|
const MAX_PAYLOAD_DEPTH = 2;
|
|
504
730
|
|
|
731
|
+
/**
|
|
732
|
+
* How deep `scanCommand` follows nested `$(`, backticks and `(` subshells.
|
|
733
|
+
* Stack safety, fail-CLOSED (#1665): unbounded recursion overflowed the stack,
|
|
734
|
+
* and the fail-open crash handler turned that into an ALLOW. Past this cap the
|
|
735
|
+
* scan counts as unparseable, which denies a ledger-naming command.
|
|
736
|
+
*/
|
|
737
|
+
const MAX_SCAN_NESTING = 256;
|
|
738
|
+
|
|
505
739
|
/** Unquoted characters that end a shell WORD. */
|
|
506
740
|
const WORD_END = new Set([';', '|', '&', '<', '>', '(', ')', '\n']);
|
|
507
741
|
|
|
@@ -531,6 +765,58 @@ function refersToLedger(target) {
|
|
|
531
765
|
return path.posix.basename(normalized) === LEDGER_BASENAME;
|
|
532
766
|
}
|
|
533
767
|
|
|
768
|
+
/**
|
|
769
|
+
* Could a write target whose basename a command substitution (partly) decides
|
|
770
|
+
* be the ledger (#1671)? `target` carries {@link SUBST_PLACEHOLDER} where each
|
|
771
|
+
* substitution stood. True when the target's directory is the ledger directory
|
|
772
|
+
* and the basename skeleton — each placeholder read as "any text" — can match
|
|
773
|
+
* `sessions.jsonl`: `$(echo sessions).jsonl`, `sess$(echo ions).jsonl`, a bare
|
|
774
|
+
* `$(…)`. `$(date).log` or `events-$(date).jsonl` cannot, and stay allowed.
|
|
775
|
+
*
|
|
776
|
+
* Ceiling: only the literal `.orchestrator/metrics/` spelling is known. A name
|
|
777
|
+
* decided by a VARIABLE (`$F`), or a dynamic name after `cd` into the metrics
|
|
778
|
+
* dir, is not judged. Measured still-ALLOW pre-existing forms (security review):
|
|
779
|
+
* - parameter expansion: `${x}.jsonl`, `$x.jsonl` (no substitution placeholder)
|
|
780
|
+
* - brace expansion: `{sessions,x}.jsonl`
|
|
781
|
+
* - globs: `session?.jsonl`, `sessions.json[l]`
|
|
782
|
+
* - ANSI-C hex escapes: `$'\x73essions'` (escape kept literal, not decoded)
|
|
783
|
+
* - the `install` / `rsync` verbs (no write-verb branch)
|
|
784
|
+
* - the prefilter needs the literal `.orchestrator/metrics/` spelling, so
|
|
785
|
+
* `.orchestrator/x/../metrics/$(…)` is never normalized or judged
|
|
786
|
+
* Revisit trigger: a measured ledger write of any of these shapes, or a
|
|
787
|
+
* sessions.jsonl corruption whose writer used one.
|
|
788
|
+
*
|
|
789
|
+
* @param {string} target
|
|
790
|
+
* @returns {boolean}
|
|
791
|
+
*/
|
|
792
|
+
function mayNameLedgerDynamically(target) {
|
|
793
|
+
if (typeof target !== 'string' || !target.includes(SUBST_PLACEHOLDER)) return false;
|
|
794
|
+
const normalized = target.replace(/\\/g, '/');
|
|
795
|
+
const dir = path.posix.normalize(path.posix.dirname(normalized));
|
|
796
|
+
if (dir !== LEDGER_DIR && !dir.endsWith(`/${LEDGER_DIR}`)) return false;
|
|
797
|
+
const skeleton = path.posix.basename(normalized)
|
|
798
|
+
.split(SUBST_PLACEHOLDER)
|
|
799
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
800
|
+
.join('.*');
|
|
801
|
+
return new RegExp(`^${skeleton}$`).test(LEDGER_BASENAME);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* The write-target verdict every write-verb branch shares: the target itself
|
|
806
|
+
* when it names the ledger, a fail-closed label when only a substitution could
|
|
807
|
+
* decide that (#1671), else null.
|
|
808
|
+
*
|
|
809
|
+
* @param {string} target
|
|
810
|
+
* @returns {string|null}
|
|
811
|
+
*/
|
|
812
|
+
function ledgerWriteHit(target) {
|
|
813
|
+
if (refersToLedger(target)) return target;
|
|
814
|
+
if (mayNameLedgerDynamically(target)) {
|
|
815
|
+
return `${target.split(SUBST_PLACEHOLDER).join('$(…)')} (command substitution decides the target — not statically resolvable, denied fail-closed)`;
|
|
816
|
+
}
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
|
|
534
820
|
/**
|
|
535
821
|
* Does this token name the repair CLI by its immediate script operand basename?
|
|
536
822
|
*
|
|
@@ -553,11 +839,13 @@ function refersToRepairScript(target) {
|
|
|
553
839
|
* Deliberately narrow: this is NOT a general shell parser. It exists only to
|
|
554
840
|
* answer "is this string exactly the canonical repair-apply command sitting
|
|
555
841
|
* inside one layer of command-substitution/subshell syntax" (see
|
|
556
|
-
* {@link unwrapSubshellLayers} and GitLab #385 test (c))
|
|
557
|
-
* `blocker.tokenizeCommand`
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
842
|
+
* {@link unwrapSubshellLayers} and GitLab #385 test (c)). History: the shared
|
|
843
|
+
* `blocker.tokenizeCommand` once glued a leading `$(`/`` ` ``/`(` onto the next
|
|
844
|
+
* word (#385 measured `$(node` as one token), so the verb matcher never saw
|
|
845
|
+
* `node` in a wrapped command. That is no longer true — `scanCommand` (#1665)
|
|
846
|
+
* and the shared tokenizer (#1667, e6adbcc7) both split those spans. The unwrapping stays
|
|
847
|
+
* because two checks in `main()` still need the wrapped command's interior as
|
|
848
|
+
* a STRING: the absolute-operand regex and the dedicated "wrapped" deny text.
|
|
561
849
|
*
|
|
562
850
|
* @param {string} s - already-trimmed candidate
|
|
563
851
|
* @returns {string|null} the unwrapped, trimmed interior, or null
|
|
@@ -761,35 +1049,67 @@ function skipQuotedRun(command, open, escapes) {
|
|
|
761
1049
|
* `.orchestrator/metrics/"sessions.jsonl"`, `My\ Repo/…/sessions.jsonl` and
|
|
762
1050
|
* `"."/…/sessions.jsonl` all resolve to a value whose basename is the ledger.
|
|
763
1051
|
*
|
|
1052
|
+
* #1670 — with `onSubstitution`, an unquoted OR double-quoted `$(…)` or backtick span is part of
|
|
1053
|
+
* the word instead of ending it at its `(`: `>> $(pwd)/…/sessions.jsonl` used to
|
|
1054
|
+
* read as the target `$`, after which the scanner took `(pwd)` for a subshell and
|
|
1055
|
+
* never judged the write. The span's raw text stands in the value, so the
|
|
1056
|
+
* basename is still the static last component (a span's `)` / backtick can never
|
|
1057
|
+
* end in `sessions.jsonl`). `dynamic` reports whether a span sits in that LAST
|
|
1058
|
+
* component, i.e. whether the basename is not statically known.
|
|
1059
|
+
*
|
|
764
1060
|
* @param {string} command
|
|
765
1061
|
* @param {number} i
|
|
766
|
-
* @
|
|
1062
|
+
* @param {(bodyStart: number, stop: string) => number} [onSubstitution] - scans
|
|
1063
|
+
* the body and returns the index just past its closing character, or -1
|
|
1064
|
+
* @returns {{ value: string, end: number, balanced: boolean, dynamic: boolean }}
|
|
767
1065
|
*/
|
|
768
|
-
function readWord(command, i) {
|
|
1066
|
+
function readWord(command, i, onSubstitution) {
|
|
769
1067
|
let value = '';
|
|
1068
|
+
// `value` with every substitution replaced by SUBST_PLACEHOLDER (#1671).
|
|
1069
|
+
let skeleton = '';
|
|
770
1070
|
let state = 'normal';
|
|
1071
|
+
let dynamic = false;
|
|
1072
|
+
const add = (text) => {
|
|
1073
|
+
value += text;
|
|
1074
|
+
skeleton += text;
|
|
1075
|
+
if (text.includes('/')) dynamic = false;
|
|
1076
|
+
};
|
|
771
1077
|
while (i < command.length) {
|
|
772
1078
|
const ch = command[i];
|
|
773
1079
|
|
|
774
1080
|
if (state === 'single') {
|
|
775
1081
|
if (ch === "'") { state = 'normal'; i++; continue; }
|
|
776
|
-
|
|
1082
|
+
add(ch); i++; continue;
|
|
777
1083
|
}
|
|
778
1084
|
if (state === 'double' || state === 'ansi') {
|
|
779
1085
|
if (ch === (state === 'double' ? '"' : "'")) { state = 'normal'; i++; continue; }
|
|
780
|
-
if (ch === '\\' && i + 1 < command.length) {
|
|
781
|
-
|
|
1086
|
+
if (ch === '\\' && i + 1 < command.length) { add(command[i + 1]); i += 2; continue; }
|
|
1087
|
+
// Bash expands `$(…)` and backticks inside "…" (never inside '…' or $'…'),
|
|
1088
|
+
// so a double-quoted span decides the name exactly like an unquoted one.
|
|
1089
|
+
if (!(state === 'double' && onSubstitution && ((ch === '$' && command[i + 1] === '(') || ch === '`'))) {
|
|
1090
|
+
add(ch); i++; continue;
|
|
1091
|
+
}
|
|
782
1092
|
}
|
|
783
1093
|
|
|
1094
|
+
if (onSubstitution && ((ch === '$' && command[i + 1] === '(') || ch === '`')) {
|
|
1095
|
+
const isDollarParen = ch === '$';
|
|
1096
|
+
const end = onSubstitution(isDollarParen ? i + 2 : i + 1, isDollarParen ? ')' : '`');
|
|
1097
|
+
if (end === -1) return { value, skeleton, end: i, balanced: false, dynamic: true };
|
|
1098
|
+
value += command.slice(i, end);
|
|
1099
|
+
skeleton += SUBST_PLACEHOLDER;
|
|
1100
|
+
dynamic = true;
|
|
1101
|
+
i = end;
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
784
1104
|
if (/\s/.test(ch) || WORD_END.has(ch)) break;
|
|
785
|
-
if (ch === '\\' && i + 1 < command.length) {
|
|
1105
|
+
if (ch === '\\' && i + 1 < command.length) { add(command[i + 1]); i += 2; continue; }
|
|
786
1106
|
if (ch === "'") { state = 'single'; i++; continue; }
|
|
787
1107
|
if (ch === '"') { state = 'double'; i++; continue; }
|
|
788
1108
|
if (ch === '$' && command[i + 1] === "'") { state = 'ansi'; i += 2; continue; }
|
|
789
1109
|
if (ch === '$' && command[i + 1] === '"') { state = 'double'; i += 2; continue; }
|
|
790
|
-
|
|
1110
|
+
add(ch); i++;
|
|
791
1111
|
}
|
|
792
|
-
return { value, end: i, balanced: state === 'normal' };
|
|
1112
|
+
return { value, skeleton, end: i, balanced: state === 'normal', dynamic };
|
|
793
1113
|
}
|
|
794
1114
|
|
|
795
1115
|
/**
|
|
@@ -821,107 +1141,268 @@ function skipHeredocBody(command, from, delim, stripTabs) {
|
|
|
821
1141
|
* Returns every RESOLVED redirect target, plus a sanitized command for the
|
|
822
1142
|
* write-verb matcher: comments and here-doc bodies removed, `$'…'` folded to a
|
|
823
1143
|
* plain single-quoted literal, redirect operators and their targets elided.
|
|
824
|
-
* `balanced` is false when the scan ended inside an unterminated quote
|
|
825
|
-
* command bash would reject, and the fail-closed trigger
|
|
1144
|
+
* `balanced` is false when the scan ended inside an unterminated quote or
|
|
1145
|
+
* substitution — a command bash would reject, and the fail-closed trigger
|
|
1146
|
+
* (see docblock).
|
|
1147
|
+
*
|
|
1148
|
+
* #1665 — subshells, command substitutions and brace groups. The shared
|
|
1149
|
+
* tokenizer treats `(`, `$(`, `` ` ``, `{` as ordinary word characters, so
|
|
1150
|
+
* `true && (node -e …)` lexed `(node` as one word and every verb-keyed check
|
|
1151
|
+
* went blind inside ANY wrapper. This pass therefore parses them as spans:
|
|
1152
|
+
*
|
|
1153
|
+
* - `$(…)` and `` `…` `` — quoted or not — are scanned recursively to their
|
|
1154
|
+
* quote-aware closing `)` / backtick (a `"` inside the body opens a NEW
|
|
1155
|
+
* quoting context, as in bash, and no longer ends the outer `"…"`). The
|
|
1156
|
+
* body is returned in `substitutions` for the caller to judge as its own
|
|
1157
|
+
* command line, and the span becomes SUBST_PLACEHOLDER (a `_`) in `sanitized`, so
|
|
1158
|
+
* the surrounding WORD stays whole: `tee -a $(pwd)/…/sessions.jsonl` reads
|
|
1159
|
+
* as `tee -a _/…/sessions.jsonl`, not as three statements.
|
|
1160
|
+
* - a bare `( … )` subshell and a `{ … ; }` group (reserved words only —
|
|
1161
|
+
* never `{}` or `${…}`/`{a,b}`) become `;` separators, which puts the inner
|
|
1162
|
+
* command at the head of its own segment.
|
|
1163
|
+
* - redirect targets inside any span land in the one shared `targets` list.
|
|
1164
|
+
*
|
|
1165
|
+
* Only DIRECT substitution bodies are returned; a body nested in a body is
|
|
1166
|
+
* found when the caller re-scans the outer one (bounded by MAX_PAYLOAD_DEPTH).
|
|
1167
|
+
* When a substitution inside double quotes cannot be parsed, the run falls back
|
|
1168
|
+
* to the plain quote skip and `unparsedSubstitution` is set: the body is NOT
|
|
1169
|
+
* judged and the command is NOT failed closed — the top-level command was
|
|
1170
|
+
* balanced, so bash may well run it (a mark, never a deny).
|
|
1171
|
+
*
|
|
1172
|
+
* NAMED CEILING (BV-004): a `case` pattern's `)` inside a substitution ends the
|
|
1173
|
+
* body early (the bash 3.2 reading). Revisit if a measured transcript command
|
|
1174
|
+
* carries a `case` inside `$(…)` next to the ledger.
|
|
826
1175
|
*
|
|
827
1176
|
* @param {string} command
|
|
828
|
-
* @returns {{ targets: string[],
|
|
1177
|
+
* @returns {{ targets: string[], unresolvedTargets: string[], sanitized: string,
|
|
1178
|
+
* balanced: boolean, substitutions: string[], unparsedSubstitution: boolean }}
|
|
829
1179
|
*/
|
|
830
1180
|
function scanCommand(command) {
|
|
831
1181
|
const targets = [];
|
|
1182
|
+
// #1670 — redirect targets whose basename a substitution supplies (see readWord).
|
|
1183
|
+
const unresolvedTargets = [];
|
|
1184
|
+
const substitutions = [];
|
|
832
1185
|
const heredocs = [];
|
|
833
|
-
let
|
|
834
|
-
|
|
1186
|
+
let unparsedSubstitution = false;
|
|
1187
|
+
// MAX_SCAN_NESTING bookkeeping: current span depth, and whether any span hit
|
|
1188
|
+
// the cap. `tooDeep` overrides the quoted-run fallback in scanDouble, which
|
|
1189
|
+
// would otherwise downgrade the overflow to a mere `unparsed-substitution` mark.
|
|
1190
|
+
let nesting = 0;
|
|
1191
|
+
let tooDeep = false;
|
|
835
1192
|
|
|
836
1193
|
const atWordStart = (idx) => idx === 0 || /[\s;|&()<>]/.test(command[idx - 1]);
|
|
837
1194
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1195
|
+
/**
|
|
1196
|
+
* Scan a substitution body from `bodyStart` to its `stop` character and record
|
|
1197
|
+
* the body in `subs`. Nested bodies go to a throwaway list (see above).
|
|
1198
|
+
*
|
|
1199
|
+
* @returns {number} index just past the closing character, or -1 when unbalanced
|
|
1200
|
+
*/
|
|
1201
|
+
function substitution(bodyStart, stop, subs) {
|
|
1202
|
+
const inner = scanSpan(bodyStart, stop, []);
|
|
1203
|
+
if (!inner) return -1;
|
|
1204
|
+
subs.push(command.slice(bodyStart, inner.end - 1));
|
|
1205
|
+
return inner.end;
|
|
1206
|
+
}
|
|
847
1207
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1208
|
+
/**
|
|
1209
|
+
* A double-quoted run opening at `open`. Text is copied verbatim except that
|
|
1210
|
+
* each substitution becomes `_`.
|
|
1211
|
+
*
|
|
1212
|
+
* @returns {{ out: string, end: number } | null} null when never closed
|
|
1213
|
+
*/
|
|
1214
|
+
function scanDouble(open, subs) {
|
|
1215
|
+
const saved = {
|
|
1216
|
+
targets: targets.length, unresolved: unresolvedTargets.length, heredocs: heredocs.slice(), subs: subs.length,
|
|
1217
|
+
};
|
|
1218
|
+
let out = '"';
|
|
1219
|
+
let i = open + 1;
|
|
1220
|
+
while (i < command.length) {
|
|
1221
|
+
const ch = command[i];
|
|
1222
|
+
if (ch === '"') return { out: out + '"', end: i + 1 };
|
|
1223
|
+
if (ch === '\\' && i + 1 < command.length) { out += ch + command[i + 1]; i += 2; continue; }
|
|
1224
|
+
const isDollarParen = ch === '$' && command[i + 1] === '(';
|
|
1225
|
+
if (isDollarParen || ch === '`') {
|
|
1226
|
+
const end = substitution(isDollarParen ? i + 2 : i + 1, isDollarParen ? ')' : '`', subs);
|
|
1227
|
+
if (end === -1) {
|
|
1228
|
+
// Unparseable body inside a quoted run: undo what the failed attempt
|
|
1229
|
+
// recorded, skip the run the plain way, and mark it (#1665).
|
|
1230
|
+
targets.length = saved.targets;
|
|
1231
|
+
unresolvedTargets.length = saved.unresolved;
|
|
1232
|
+
heredocs.splice(0, heredocs.length, ...saved.heredocs);
|
|
1233
|
+
subs.length = saved.subs;
|
|
1234
|
+
const plainEnd = skipQuotedRun(command, open, true);
|
|
1235
|
+
if (plainEnd === -1) return null;
|
|
1236
|
+
unparsedSubstitution = true;
|
|
1237
|
+
return { out: command.slice(open, plainEnd), end: plainEnd };
|
|
1238
|
+
}
|
|
1239
|
+
out += SUBST_PLACEHOLDER;
|
|
1240
|
+
i = end;
|
|
1241
|
+
continue;
|
|
854
1242
|
}
|
|
855
|
-
|
|
1243
|
+
out += ch;
|
|
1244
|
+
i++;
|
|
856
1245
|
}
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
857
1248
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1249
|
+
/**
|
|
1250
|
+
* Scan from `i` up to `stop` (`)` or a backtick), or to the end of input when
|
|
1251
|
+
* `stop` is null.
|
|
1252
|
+
*
|
|
1253
|
+
* @returns {{ out: string, end: number } | null} `end` is just past `stop`;
|
|
1254
|
+
* null when the input ends first (or inside an unterminated quote)
|
|
1255
|
+
*/
|
|
1256
|
+
function scanSpan(i, stop, subs) {
|
|
1257
|
+
if (nesting >= MAX_SCAN_NESTING) {
|
|
1258
|
+
tooDeep = true;
|
|
1259
|
+
return null;
|
|
869
1260
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
continue;
|
|
876
|
-
}
|
|
877
|
-
if (ch === "'" || ch === '"') {
|
|
878
|
-
const end = skipQuotedRun(command, i, ch === '"');
|
|
879
|
-
if (end === -1) return { targets, sanitized: out, balanced: false };
|
|
880
|
-
out += command.slice(i, end);
|
|
881
|
-
i = end;
|
|
882
|
-
continue;
|
|
1261
|
+
nesting++;
|
|
1262
|
+
try {
|
|
1263
|
+
return scanSpanBody(i, stop, subs);
|
|
1264
|
+
} finally {
|
|
1265
|
+
nesting--;
|
|
883
1266
|
}
|
|
1267
|
+
}
|
|
884
1268
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
let stripTabs = false;
|
|
890
|
-
if (command[j] === '-') { stripTabs = true; j++; }
|
|
891
|
-
while (command[j] === ' ' || command[j] === '\t') j++;
|
|
892
|
-
const w = readWord(command, j);
|
|
893
|
-
if (!w.balanced) return { targets, sanitized: out, balanced: false };
|
|
894
|
-
if (w.value) heredocs.push({ delim: w.value, stripTabs });
|
|
895
|
-
out += ' ';
|
|
896
|
-
i = Math.max(w.end, i + 2);
|
|
897
|
-
continue;
|
|
898
|
-
}
|
|
1269
|
+
function scanSpanBody(i, stop, subs) {
|
|
1270
|
+
let out = '';
|
|
1271
|
+
while (i < command.length) {
|
|
1272
|
+
const ch = command[i];
|
|
899
1273
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
if (
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const dup = readWord(command, j + 1);
|
|
908
|
-
if (FD_DUP_RE.test(dup.value)) { out += ' '; i = Math.max(dup.end, j + 1); continue; }
|
|
909
|
-
j += 1;
|
|
1274
|
+
if (stop !== null && ch === stop) return { out, end: i + 1 };
|
|
1275
|
+
|
|
1276
|
+
// A `#` in word position comments out the rest of the LINE. Without this,
|
|
1277
|
+
// one apostrophe in English prose desynced the whole scan (MED-1).
|
|
1278
|
+
if (ch === '#' && atWordStart(i)) {
|
|
1279
|
+
while (i < command.length && command[i] !== '\n') i++;
|
|
1280
|
+
continue;
|
|
910
1281
|
}
|
|
911
|
-
while (command[j] === ' ' || command[j] === '\t') j++;
|
|
912
|
-
const w = readWord(command, j);
|
|
913
|
-
if (!w.balanced) return { targets, sanitized: out, balanced: false };
|
|
914
|
-
if (w.value) targets.push(w.value);
|
|
915
|
-
out += ' ';
|
|
916
|
-
i = Math.max(w.end, j, i + 1);
|
|
917
|
-
continue;
|
|
918
|
-
}
|
|
919
1282
|
|
|
920
|
-
|
|
921
|
-
|
|
1283
|
+
if (ch === '\n') {
|
|
1284
|
+
out += '\n';
|
|
1285
|
+
i++;
|
|
1286
|
+
while (heredocs.length) {
|
|
1287
|
+
const { delim, stripTabs } = heredocs.shift();
|
|
1288
|
+
i = skipHeredocBody(command, i, delim, stripTabs);
|
|
1289
|
+
}
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (ch === '\\' && i + 1 < command.length) { out += ch + command[i + 1]; i += 2; continue; }
|
|
1294
|
+
|
|
1295
|
+
// ANSI-C `$'…'`: a backslash escapes the closing quote, which the old mask
|
|
1296
|
+
// did not know (MED-1). Folded to a plain literal for the write-verb lexer.
|
|
1297
|
+
if (ch === '$' && command[i + 1] === "'") {
|
|
1298
|
+
const end = skipQuotedRun(command, i + 1, true);
|
|
1299
|
+
if (end === -1) return null;
|
|
1300
|
+
const literal = command.slice(i + 2, end - 1).replace(/\\(.)/g, '$1').replace(/'/g, '');
|
|
1301
|
+
out += `'${literal}'`;
|
|
1302
|
+
i = end;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
// `$"…"` (locale string) is a double-quoted run without its `$`.
|
|
1306
|
+
if (ch === '"' || (ch === '$' && command[i + 1] === '"')) {
|
|
1307
|
+
const run = scanDouble(ch === '"' ? i : i + 1, subs);
|
|
1308
|
+
if (!run) return null;
|
|
1309
|
+
out += run.out;
|
|
1310
|
+
i = run.end;
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
if (ch === "'") {
|
|
1314
|
+
const end = skipQuotedRun(command, i, false);
|
|
1315
|
+
if (end === -1) return null;
|
|
1316
|
+
out += command.slice(i, end);
|
|
1317
|
+
i = end;
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Here-doc: remember the delimiter now, skip the body at the next newline.
|
|
1322
|
+
// `<<<` is a here-STRING, not a here-doc, and needs no body skipping.
|
|
1323
|
+
if (ch === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
|
|
1324
|
+
let j = i + 2;
|
|
1325
|
+
let stripTabs = false;
|
|
1326
|
+
if (command[j] === '-') { stripTabs = true; j++; }
|
|
1327
|
+
while (command[j] === ' ' || command[j] === '\t') j++;
|
|
1328
|
+
const w = readWord(command, j);
|
|
1329
|
+
if (!w.balanced) return null;
|
|
1330
|
+
if (w.value) heredocs.push({ delim: w.value, stripTabs });
|
|
1331
|
+
out += ' ';
|
|
1332
|
+
i = Math.max(w.end, i + 2);
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Redirection: `>` `>>` `>|` `N>` `&>` `&>>`. The optional leading fd digits
|
|
1337
|
+
// are ordinary characters we already copied; only the operator matters.
|
|
1338
|
+
if (ch === '>' || (ch === '&' && command[i + 1] === '>')) {
|
|
1339
|
+
let j = ch === '&' ? i + 2 : i + 1;
|
|
1340
|
+
if (command[j] === '>') j++;
|
|
1341
|
+
if (command[j] === '|') j++;
|
|
1342
|
+
if (command[j] === '&') {
|
|
1343
|
+
const dup = readWord(command, j + 1);
|
|
1344
|
+
if (FD_DUP_RE.test(dup.value)) { out += ' '; i = Math.max(dup.end, j + 1); continue; }
|
|
1345
|
+
j += 1;
|
|
1346
|
+
}
|
|
1347
|
+
while (command[j] === ' ' || command[j] === '\t') j++;
|
|
1348
|
+
// #1670 — a substitution inside the target belongs to the word; its body
|
|
1349
|
+
// is still recorded and judged as its own command line.
|
|
1350
|
+
const w = readWord(command, j, (bodyStart, stop) => substitution(bodyStart, stop, subs));
|
|
1351
|
+
if (!w.balanced) return null;
|
|
1352
|
+
if (w.value) targets.push(w.value);
|
|
1353
|
+
// The basename comes from a substitution and the word mentions the
|
|
1354
|
+
// ledger — or (#1671) the substitution could spell part of its name in
|
|
1355
|
+
// the ledger directory: not statically resolvable, judged fail-closed.
|
|
1356
|
+
if (w.dynamic && !refersToLedger(w.value) &&
|
|
1357
|
+
(w.value.includes(LEDGER_BASENAME) || mayNameLedgerDynamically(w.skeleton))) {
|
|
1358
|
+
unresolvedTargets.push(w.value);
|
|
1359
|
+
}
|
|
1360
|
+
out += ' ';
|
|
1361
|
+
i = Math.max(w.end, j, i + 1);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// #1665 — substitutions keep their word whole (placeholder), subshells and
|
|
1366
|
+
// brace groups open a new command position (separator). See the docblock.
|
|
1367
|
+
const isDollarParen = ch === '$' && command[i + 1] === '(';
|
|
1368
|
+
if (isDollarParen || ch === '`') {
|
|
1369
|
+
const end = substitution(isDollarParen ? i + 2 : i + 1, isDollarParen ? ')' : '`', subs);
|
|
1370
|
+
if (end === -1) return null;
|
|
1371
|
+
out += SUBST_PLACEHOLDER;
|
|
1372
|
+
i = end;
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
if (ch === '(') {
|
|
1376
|
+
const inner = scanSpan(i + 1, ')', subs);
|
|
1377
|
+
if (!inner) return null;
|
|
1378
|
+
out += ` ; ${inner.out} ; `;
|
|
1379
|
+
i = inner.end;
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
// A `)` nothing here is waiting for (a `case` pattern, a stray close).
|
|
1383
|
+
if (ch === ')') { out += ' ; '; i++; continue; }
|
|
1384
|
+
if (ch === '{' && atWordStart(i) && /\s/.test(command[i + 1] ?? '')) { out += ' ; '; i++; continue; }
|
|
1385
|
+
if (ch === '}' && atWordStart(i) && (i + 1 === command.length || /[\s;|&)`]/.test(command[i + 1]))) {
|
|
1386
|
+
out += ' ; ';
|
|
1387
|
+
i++;
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
out += ch;
|
|
1392
|
+
i++;
|
|
1393
|
+
}
|
|
1394
|
+
return stop === null ? { out, end: i } : null;
|
|
922
1395
|
}
|
|
923
1396
|
|
|
924
|
-
|
|
1397
|
+
const top = scanSpan(0, null, substitutions);
|
|
1398
|
+
return {
|
|
1399
|
+
targets,
|
|
1400
|
+
unresolvedTargets,
|
|
1401
|
+
sanitized: top ? top.out : '',
|
|
1402
|
+
balanced: top !== null && !tooDeep,
|
|
1403
|
+
substitutions,
|
|
1404
|
+
unparsedSubstitution,
|
|
1405
|
+
};
|
|
925
1406
|
}
|
|
926
1407
|
|
|
927
1408
|
// ---------------------------------------------------------------------------
|
|
@@ -1271,7 +1752,8 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
|
|
|
1271
1752
|
// lives beside that table (command-blocker.mjs, the WRAPPER_UNWRAP docblock).
|
|
1272
1753
|
for (const wa of wrapperArgs) {
|
|
1273
1754
|
if (wa.writesFile !== true) continue;
|
|
1274
|
-
|
|
1755
|
+
const hit = typeof wa.value === 'string' ? ledgerWriteHit(wa.value) : null;
|
|
1756
|
+
if (hit) return hit;
|
|
1275
1757
|
}
|
|
1276
1758
|
}
|
|
1277
1759
|
|
|
@@ -1321,7 +1803,17 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
|
|
|
1321
1803
|
// actual script operand after Node runtime options, a later exact
|
|
1322
1804
|
// `--apply`, and no later exact `--dry-run`. The CLI remains the owner of
|
|
1323
1805
|
// target semantics.
|
|
1324
|
-
|
|
1806
|
+
//
|
|
1807
|
+
// NAMED CEILING (BV-004, #1671 item 4): the rule keys on the LITERAL
|
|
1808
|
+
// script basename in argv. Not recognised, and therefore allowed: a glob
|
|
1809
|
+
// as the script name (`repair-invalid-session?.mjs` — the shell expands
|
|
1810
|
+
// it), a symlink or copy under another name, the script run via `xargs`,
|
|
1811
|
+
// loaded as a module (`bun -e 'import(…)'`, `node --import <script>`), or
|
|
1812
|
+
// piped into node on stdin. Each needs either filesystem resolution or
|
|
1813
|
+
// payload evaluation this lexical guard does not do; the CLI itself stays
|
|
1814
|
+
// the owner of its apply semantics. REVISIT TRIGGER: a repair --apply
|
|
1815
|
+
// reaching the ledger through one of these shapes is observed.
|
|
1816
|
+
if (NODE_VERBS.has(verb)) {
|
|
1325
1817
|
const script = resolveNodeScriptOperand(args);
|
|
1326
1818
|
const hasRepairMention = args.some((arg) => refersToRepairScript(arg.text));
|
|
1327
1819
|
const hasApply = args.some((arg) => arg.text === '--apply');
|
|
@@ -1346,12 +1838,31 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
|
|
|
1346
1838
|
return REPAIR_APPLY_MARKER;
|
|
1347
1839
|
}
|
|
1348
1840
|
}
|
|
1841
|
+
} else if (!TEXT_ONLY_VERBS.has(verb)) {
|
|
1842
|
+
// #1670 — the allowance is for `node` only (the closed grammar in
|
|
1843
|
+
// parseRepairApply). Any OTHER runtime or launcher that runs the repair
|
|
1844
|
+
// CLI — `bun`, `bun run`, `deno run -A`, `npx node`, `npm exec -- node`,
|
|
1845
|
+
// a node resolved via `$(which node)` (verb `_`) — mutates the ledger
|
|
1846
|
+
// just the same, and used to ALLOW because this branch only knew Node's
|
|
1847
|
+
// verb names: a broader bypass than `node` itself had. Rule: such a
|
|
1848
|
+
// segment is judged like a node repair-apply WITHOUT the allowance, i.e.
|
|
1849
|
+
// denied whenever the script operand is followed by `--apply` and no
|
|
1850
|
+
// `--dry-run`. Keyed on the operand rather than a runner list, because a
|
|
1851
|
+
// runner list cannot enumerate the next launcher. Verbs that only print
|
|
1852
|
+
// or search text (TEXT_ONLY_VERBS: `echo …`, `grep … --apply`) never run
|
|
1853
|
+
// the script and are exempt — they were false positives of this rule.
|
|
1854
|
+
const at = args.findIndex((arg) => refersToRepairScript(arg.text));
|
|
1855
|
+
const laterArgs = at === -1 ? [] : args.slice(at + 1);
|
|
1856
|
+
if (laterArgs.some((arg) => arg.text === '--apply') && !laterArgs.some((arg) => arg.text === '--dry-run')) {
|
|
1857
|
+
return `${REPAIR_APPLY_MARKER} (run by '${verb}', not node — only the node forms are sanctioned)`;
|
|
1858
|
+
}
|
|
1349
1859
|
}
|
|
1350
1860
|
|
|
1351
1861
|
if (verb === 'tee') {
|
|
1352
1862
|
for (const arg of args) {
|
|
1353
1863
|
if (!arg.quoted && arg.text.startsWith('-')) continue;
|
|
1354
|
-
|
|
1864
|
+
const hit = ledgerWriteHit(arg.text);
|
|
1865
|
+
if (hit) return hit;
|
|
1355
1866
|
}
|
|
1356
1867
|
continue;
|
|
1357
1868
|
}
|
|
@@ -1359,7 +1870,8 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
|
|
|
1359
1870
|
if (verb === 'dd') {
|
|
1360
1871
|
for (const arg of args) {
|
|
1361
1872
|
const m = /^of=(.*)$/.exec(arg.text);
|
|
1362
|
-
|
|
1873
|
+
const hit = m ? ledgerWriteHit(m[1]) : null;
|
|
1874
|
+
if (hit) return hit;
|
|
1363
1875
|
}
|
|
1364
1876
|
continue;
|
|
1365
1877
|
}
|
|
@@ -1368,7 +1880,8 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
|
|
|
1368
1880
|
const operands = args.filter((a) => a.quoted || !a.text.startsWith('-'));
|
|
1369
1881
|
// `cp a b` writes b; `cp ledger backup` READS the ledger and must pass.
|
|
1370
1882
|
const dest = operands.length >= 2 ? operands[operands.length - 1] : null;
|
|
1371
|
-
|
|
1883
|
+
const hit = dest ? ledgerWriteHit(dest.text) : null;
|
|
1884
|
+
if (hit) return hit;
|
|
1372
1885
|
}
|
|
1373
1886
|
}
|
|
1374
1887
|
}
|
|
@@ -1393,8 +1906,15 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
|
|
|
1393
1906
|
function findLedgerWrite(command, depth = 0, marks = []) {
|
|
1394
1907
|
if (typeof command !== 'string' || command.length === 0) return null;
|
|
1395
1908
|
// Cheap pre-filter: commands naming neither the ledger nor the internal repair
|
|
1396
|
-
// CLI cannot reach either deny path.
|
|
1397
|
-
|
|
1909
|
+
// CLI cannot reach either deny path. #1671: judged with quotes and backslashes
|
|
1910
|
+
// stripped (`"sessions".jsonl`, `sessions\.jsonl` name the ledger to bash),
|
|
1911
|
+
// and a substitution in the ledger directory passes too — it may spell the name.
|
|
1912
|
+
const unquoted = command.replace(/["'\\]/g, '');
|
|
1913
|
+
if (
|
|
1914
|
+
!unquoted.includes(LEDGER_BASENAME) &&
|
|
1915
|
+
!unquoted.includes(REPAIR_SCRIPT_BASENAME) &&
|
|
1916
|
+
!(unquoted.includes(`${LEDGER_DIR}/`) && /\$\(|`/.test(command))
|
|
1917
|
+
) return null;
|
|
1398
1918
|
|
|
1399
1919
|
const scan = scanCommand(command);
|
|
1400
1920
|
for (const target of scan.targets) {
|
|
@@ -1407,7 +1927,27 @@ function findLedgerWrite(command, depth = 0, marks = []) {
|
|
|
1407
1927
|
if (!scan.balanced) {
|
|
1408
1928
|
return `${LEDGER_BASENAME} (unbalanced quote — command not parseable, denied fail-closed)`;
|
|
1409
1929
|
}
|
|
1410
|
-
|
|
1930
|
+
// #1670 — same direction for a redirect target whose basename only a command
|
|
1931
|
+
// substitution decides (`> $(echo …/sessions.jsonl)`): the guard cannot know
|
|
1932
|
+
// what it names, and the word mentions the ledger.
|
|
1933
|
+
if (scan.unresolvedTargets.length > 0) {
|
|
1934
|
+
return `${scan.unresolvedTargets[0]} (command substitution decides the target — not statically resolvable, denied fail-closed)`;
|
|
1935
|
+
}
|
|
1936
|
+
if (scan.unparsedSubstitution) marks.push('unparsed-substitution');
|
|
1937
|
+
const direct = findWriteVerbTarget(scan.sanitized, depth, marks);
|
|
1938
|
+
if (direct) return direct;
|
|
1939
|
+
// #1665 — every `$(…)` / backtick body, quoted or not, runs as its own
|
|
1940
|
+
// command line; judged by the SAME matcher, bounded like every other payload
|
|
1941
|
+
// recursion.
|
|
1942
|
+
for (const body of scan.substitutions) {
|
|
1943
|
+
if (depth >= MAX_PAYLOAD_DEPTH) {
|
|
1944
|
+
if (body.includes(LEDGER_BASENAME) || body.includes(REPAIR_SCRIPT_BASENAME)) marks.push('depth-exceeded');
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
const hit = findLedgerWrite(body, depth + 1, marks);
|
|
1948
|
+
if (hit) return hit;
|
|
1949
|
+
}
|
|
1950
|
+
return null;
|
|
1411
1951
|
}
|
|
1412
1952
|
|
|
1413
1953
|
/**
|
|
@@ -1474,20 +2014,48 @@ async function main() {
|
|
|
1474
2014
|
if (trimmedCommand === REPAIR_APPLY_CANONICAL_COMMAND) {
|
|
1475
2015
|
return flushNotices(notices);
|
|
1476
2016
|
}
|
|
2017
|
+
// #1577/#1656 — the closed explicit-root grammar (relative script or this
|
|
2018
|
+
// plugin's own absolute copy). Judged on the RAW command: trailing
|
|
2019
|
+
// newlines/CR and every other addition fall outside it.
|
|
2020
|
+
if (await allowsRepairApply(command)) return flushNotices(notices);
|
|
2021
|
+
// Absolute repair candidates get an explicit DENY even when shell wrapping
|
|
2022
|
+
// would hide `node` from the general matcher. Never trim the candidate here:
|
|
2023
|
+
// newlines/CR and all trailing syntax are outside this closed exception.
|
|
2024
|
+
const unwrapped = unwrapSubshellLayers(trimmedCommand);
|
|
2025
|
+
const absoluteOperand = /^(?:node|nodejs)\s+("\/[^"]+"|'\/[^']+'|\/[^\s]+)/.exec(unwrapped)?.[1];
|
|
2026
|
+
const absoluteScript = absoluteOperand?.replace(/^["']|["']$/g, '');
|
|
2027
|
+
if (refersToRepairScript(absoluteScript) && /--apply(?:[\s;|&<>)]|$)/.test(unwrapped)) {
|
|
2028
|
+
emitDeny(
|
|
2029
|
+
`Direct write to the sessions ledger blocked: '${REPAIR_APPLY_MARKER}'\n` +
|
|
2030
|
+
`Absolute repair requires this plugin's own script (${REPAIR_ABSOLUTE_SCRIPT}), --apply and ` +
|
|
2031
|
+
`exactly one --repo-root <ABS>: an absolute Git top-level without symlinks, whitespace or ` +
|
|
2032
|
+
`shell characters that holds .orchestrator/metrics/sessions.jsonl. ` +
|
|
2033
|
+
`Vault roots and descendants are forbidden repair targets. ` +
|
|
2034
|
+
`Extra syntax or an unmeasurable Git context is denied (#1577, #1656).\n` +
|
|
2035
|
+
`Sanctioned forms:\n${REPAIR_SANCTIONED_FORMS}`
|
|
2036
|
+
);
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
1477
2039
|
// The SAME canonical command wrapped in one layer of command substitution /
|
|
1478
2040
|
// backticks / a bare subshell is a DIFFERENT command — bash still executes
|
|
1479
2041
|
// the repair mutation for the substitution's side effect, but it no longer
|
|
1480
|
-
// matches the exact sanctioned string, so `sonst weiter DENY` applies.
|
|
1481
|
-
// also
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
|
|
2042
|
+
// matches the exact sanctioned string, so `sonst weiter DENY` applies. When
|
|
2043
|
+
// this was written (#385) it also closed a real gap: the tokenizer then glued
|
|
2044
|
+
// a leading `$(`/`` ` ``/`(` onto `node`, so the verb matcher below missed a
|
|
2045
|
+
// wrapped command. Since #1665/#1667 the scanner and the shared tokenizer
|
|
2046
|
+
// split those spans; this branch keeps the wrapped form's own deny text.
|
|
2047
|
+
// #1656: not only the canonical string — ANY repair-apply invocation the
|
|
2048
|
+
// matcher recognizes once unwrapped (`(node scripts/… --apply --repo-root X)`,
|
|
2049
|
+
// `$(… --apply --json)`) was a silent ALLOW at HEAD for the same reason.
|
|
2050
|
+
if (
|
|
2051
|
+
unwrapped === REPAIR_APPLY_CANONICAL_COMMAND ||
|
|
2052
|
+
(unwrapped !== trimmedCommand && findLedgerWrite(unwrapped)?.startsWith(REPAIR_APPLY_MARKER))
|
|
2053
|
+
) {
|
|
1486
2054
|
emitDeny(
|
|
1487
2055
|
[
|
|
1488
2056
|
`Direct write to the sessions ledger blocked: '${REPAIR_APPLY_MARKER} (wrapped in a subshell/backticks — denied)'`,
|
|
1489
|
-
`The sanctioned repair invocation is allowed ONLY as
|
|
1490
|
-
|
|
2057
|
+
`The sanctioned repair invocation is allowed ONLY as one of these bare commands:`,
|
|
2058
|
+
REPAIR_SANCTIONED_FORMS,
|
|
1491
2059
|
`Wrapping it in $(...), backticks, or a subshell is denied even though the`,
|
|
1492
2060
|
`command is otherwise identical — see hooks/pre-bash-sessions-ledger-guard.mjs #385.`,
|
|
1493
2061
|
`Override (intentional maintenance only): run the session with`,
|
|
@@ -1532,9 +2100,9 @@ async function main() {
|
|
|
1532
2100
|
emitDeny(
|
|
1533
2101
|
[
|
|
1534
2102
|
`Direct write to the sessions ledger blocked: '${shown}'`,
|
|
1535
|
-
`The sanctioned repair invocation is allowed ONLY as
|
|
1536
|
-
|
|
1537
|
-
`Any variant — an extra flag, a wrapper, an env prefix — is denied.`,
|
|
2103
|
+
`The sanctioned repair invocation is allowed ONLY as one of these bare commands:`,
|
|
2104
|
+
REPAIR_SANCTIONED_FORMS,
|
|
2105
|
+
`Any other variant — an extra flag, a wrapper, an env prefix — is denied.`,
|
|
1538
2106
|
`A SO_DISABLED_HOOKS=… prefix INSIDE the command has no effect: this hook reads`,
|
|
1539
2107
|
`it from the environment of the whole session, never from the command text.`,
|
|
1540
2108
|
`Override (intentional maintenance only, operator-side — e.g. started with \`!\`):`,
|