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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Check: every line under `scripts/`, `hooks/` and `skills/` that builds a path
|
|
4
|
+
* into `.orchestrator/metrics` routes it through a ledger resolver (GitLab
|
|
5
|
+
* #1722). BLOCKING.
|
|
6
|
+
*
|
|
7
|
+
* ## Why
|
|
8
|
+
*
|
|
9
|
+
* The ledgers under `.orchestrator/metrics/` are gitignored in this repo
|
|
10
|
+
* (`.gitignore:57`). A writer that joins the path to the cwd, `getProjectDir()`
|
|
11
|
+
* or an unmapped repo root writes, from a linked worktree, a copy that is lost
|
|
12
|
+
* with the worktree, and a reader built the same way sees an empty ledger.
|
|
13
|
+
* Measured 2026-10-22 before #1722: 43 of 62 sites in 54 unrouted modules,
|
|
14
|
+
* two stranded ledgers in live worktrees (`eval.jsonl`, `skill-invocations.jsonl`).
|
|
15
|
+
* A first, per-FILE version of this check passed while five readers in
|
|
16
|
+
* resolver-calling modules still read the worktree copy (review of the same
|
|
17
|
+
* day) — hence the per-LINE rule below.
|
|
18
|
+
*
|
|
19
|
+
* The resolvers that answer correctly:
|
|
20
|
+
* - `resolveLedgerPath()` / `resolveLedgerRoot()` (`scripts/lib/ledger-path.mjs`)
|
|
21
|
+
* for every ledger except events and sessions — main checkout only when
|
|
22
|
+
* the ledger is gitignored in the worktree, so repos that version it keep
|
|
23
|
+
* it on the branch;
|
|
24
|
+
* - `resolveEventsLedgerRoot()` / `explainEventsLedgerRoot()` /
|
|
25
|
+
* `eventsFilePath()` (`scripts/lib/platform.mjs`, `scripts/lib/events.mjs`)
|
|
26
|
+
* for `events.jsonl` and `sessions.jsonl` (#1514, #1584);
|
|
27
|
+
* - helpers built on them (`resolveLearningsPaths`, `defaultSkillInvocationsPath`).
|
|
28
|
+
*
|
|
29
|
+
* ## Rule
|
|
30
|
+
*
|
|
31
|
+
* A HIT is a non-comment line with a `.orchestrator/metrics` string literal or
|
|
32
|
+
* the segment pair `'.orchestrator', 'metrics'` (also split over two lines,
|
|
33
|
+
* reported on the `'.orchestrator',` line). It passes when
|
|
34
|
+
* (a) a resolver is called on that line or on one of the {@link LOOKBACK}
|
|
35
|
+
* lines above it that belong to the same statement (a multi-line
|
|
36
|
+
* `path.join(` whose root argument comes first),
|
|
37
|
+
* (b) it declares an UPPER_CASE relative constant (string, `path.join(…)` or
|
|
38
|
+
* array of segments) — the join site of the constant is checked by (2), or
|
|
39
|
+
* (c) it, or the line above, carries the marker `ledger-root: <reason>` —
|
|
40
|
+
* for a line that is no ledger access or whose root is already resolved.
|
|
41
|
+
* (2) A line that passes such a constant to `join(`/`resolve(` follows the same
|
|
42
|
+
* (a)/(c) rule.
|
|
43
|
+
* A file on {@link ALLOWLIST} is skipped whole; an allowlisted file without a
|
|
44
|
+
* HIT fails, so the list cannot rot.
|
|
45
|
+
*
|
|
46
|
+
* Ceiling: (2) sees constants declared in the same file only, and (a) trusts
|
|
47
|
+
* any resolver within the lookback window. Revisit if a stranded ledger is
|
|
48
|
+
* traced to an imported constant or to a line the window wrongly covered.
|
|
49
|
+
*
|
|
50
|
+
* @module scripts/lib/validate/check-metrics-ledger-root
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { readFileSync } from 'node:fs';
|
|
54
|
+
import path from 'node:path';
|
|
55
|
+
import { enumerateRepoFiles } from './enumerate-repo-files.mjs';
|
|
56
|
+
import { isMainModule } from '../is-main-module.mjs';
|
|
57
|
+
|
|
58
|
+
export const SCAN_DIRS = Object.freeze(['scripts', 'hooks', 'skills']);
|
|
59
|
+
|
|
60
|
+
/** Lines above a HIT in which a resolver call still counts (multi-line joins). */
|
|
61
|
+
export const LOOKBACK = 3;
|
|
62
|
+
|
|
63
|
+
/** A string literal starting with the metrics dir, or the two-segment join. */
|
|
64
|
+
const METRICS_PATH_RE = /['"`]\.orchestrator\/metrics|['"]\.orchestrator['"]\s*,\s*['"]metrics['"]/;
|
|
65
|
+
|
|
66
|
+
/** The two-segment join split over two lines: `'.orchestrator',` ⏎ `'metrics'`. */
|
|
67
|
+
const SPLIT_HEAD_RE = /['"]\.orchestrator['"]\s*,\s*$/;
|
|
68
|
+
const SPLIT_TAIL_RE = /^\s*['"]metrics['"]/;
|
|
69
|
+
|
|
70
|
+
function isHit(lines, i) {
|
|
71
|
+
return METRICS_PATH_RE.test(lines[i]) || (SPLIT_HEAD_RE.test(lines[i]) && SPLIT_TAIL_RE.test(lines[i + 1] ?? ''));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const RESOLVER_CALL_RE =
|
|
75
|
+
/\b(?:resolveLedgerPath|resolveLedgerRoot|resolveEventsLedgerRoot|explainEventsLedgerRoot|eventsFilePath|resolveLearningsPaths|defaultSkillInvocationsPath)\s*\(/;
|
|
76
|
+
|
|
77
|
+
const MARKER_RE = /ledger-root:/;
|
|
78
|
+
|
|
79
|
+
/** `const NAME = '…'`, `= path.join('…'`, `= join('…'`, `= ['…'`, optionally inside Object.freeze( */
|
|
80
|
+
const CONST_DECL_RE =
|
|
81
|
+
/^\s*(?:export\s+)?const\s+([A-Z][A-Z0-9_]*)\s*=\s*(?:Object\.freeze\(\s*)?(?:(?:path\.)?join\(\s*|\[\s*)?['"`]\.orchestrator/;
|
|
82
|
+
|
|
83
|
+
const PATH_FN_RE = /\b(?:join|resolve)\s*\(/;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Files that build a metrics path on purpose without a resolver. Each reason
|
|
87
|
+
* is the census verdict (2026-10-22): INTENDED-LOCAL (the data belongs to this
|
|
88
|
+
* working tree), NOT-A-SINK (the string is no ledger path being opened) or
|
|
89
|
+
* ROUTED-DOWNSTREAM (the path is handed to a callee that resolves each ledger).
|
|
90
|
+
* Prefer the line marker `ledger-root: <reason>` for a single line in a file
|
|
91
|
+
* that otherwise reads ledgers.
|
|
92
|
+
*/
|
|
93
|
+
export const ALLOWLIST = Object.freeze({
|
|
94
|
+
'hooks/on-session-end.mjs':
|
|
95
|
+
'ROUTED-DOWNSTREAM: hands the launch dir\'s metrics dir to the telemetry flush; buildBatch resolves each ledger in it (scripts/lib/telemetry/sync.mjs)',
|
|
96
|
+
'hooks/pre-bash-sessions-ledger-guard.mjs':
|
|
97
|
+
'NOT-A-SINK/INTENDED-LOCAL: matches command text, and probes the exact --repo-root the repair CLI is given',
|
|
98
|
+
'scripts/lib/autopilot/flags.mjs': 'constant only: the relative DEFAULT_JSONL_PATH; the loop resolves it',
|
|
99
|
+
'scripts/lib/convergence-monitor.mjs': 'NOT-A-SINK: a label and help text; the read goes through the events ledger root',
|
|
100
|
+
'scripts/lib/playwright-driver/runner.mjs':
|
|
101
|
+
'INTENDED-LOCAL: guards the per-run artifact dir, which the artifact builder roots in this tree',
|
|
102
|
+
'scripts/lib/project-hygiene.mjs': 'NOT-A-SINK: a git ls-files pathspec over this tree\'s own index',
|
|
103
|
+
'scripts/lib/quality-gate.mjs':
|
|
104
|
+
'INTENDED-LOCAL: verification-failure bundles of this tree\'s gate run, which can carry secrets',
|
|
105
|
+
'scripts/lib/quality-gates-cache.mjs':
|
|
106
|
+
'INTENDED-LOCAL: cache keyed to this tree\'s session start ref and dependency hash',
|
|
107
|
+
'scripts/lib/scope-baseline.mjs': 'NOT-A-SINK: an exclusion glob for drift counting',
|
|
108
|
+
'scripts/lib/ux-grill/manifest.mjs': 'NOT-A-SINK: a .gitignore line for the run-artifact dir',
|
|
109
|
+
'scripts/lib/vault-mirror/retention.mjs': 'NOT-A-SINK: text inside a generated note body',
|
|
110
|
+
'scripts/lib/wave-transcript-tail.mjs': 'NOT-A-SINK: help text',
|
|
111
|
+
'scripts/relocate-vault-corpus.mjs':
|
|
112
|
+
'INTENDED-LOCAL: reads each child of --repos-root as named; mapping a worktree child would count its main twice',
|
|
113
|
+
'scripts/run-migrate-v2-cross-repo.mjs': 'INTENDED-LOCAL: migrates the repos the operator names, as named',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function isCommentLine(line) {
|
|
117
|
+
const t = line.trim();
|
|
118
|
+
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Line `i` plus up to {@link LOOKBACK} lines above it that belong to the SAME
|
|
123
|
+
* statement — the walk stops at a line ending in `;`, `{` or `}`, or at a blank
|
|
124
|
+
* line, so a resolver call in the previous statement does not cover this one.
|
|
125
|
+
*/
|
|
126
|
+
function statementWindow(lines, i) {
|
|
127
|
+
const window = [lines[i]];
|
|
128
|
+
for (let j = i - 1; j >= 0 && window.length <= LOOKBACK; j -= 1) {
|
|
129
|
+
const prev = lines[j];
|
|
130
|
+
if (prev.trim() === '' || /[;{}]\s*$/.test(prev)) break;
|
|
131
|
+
window.unshift(prev);
|
|
132
|
+
}
|
|
133
|
+
return window.join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** (a) or (c) for line `i`. */
|
|
137
|
+
function covered(lines, i) {
|
|
138
|
+
if (RESOLVER_CALL_RE.test(statementWindow(lines, i))) return true;
|
|
139
|
+
return MARKER_RE.test(lines[i]) || (i > 0 && MARKER_RE.test(lines[i - 1]));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {{ pluginRoot: string, dirs?: readonly string[], allowlist?: Record<string, string> }} opts
|
|
144
|
+
* @returns {{ ok: boolean, findings: Array<{ file: string, line: number, kind: 'unrouted'|'unrouted-constant'|'stale-allowlist', text?: string }>, summary: { filesScanned: number, hits: number, resolved: number, constants: number, marked: number, allowlisted: number } }}
|
|
145
|
+
*/
|
|
146
|
+
export function scanMetricsLedgerRoot({ pluginRoot, dirs = SCAN_DIRS, allowlist = ALLOWLIST }) {
|
|
147
|
+
const files = enumerateRepoFiles({ repoRoot: pluginRoot, dirs: [...dirs], exts: ['.mjs'] });
|
|
148
|
+
const findings = [];
|
|
149
|
+
const seenAllowlisted = new Set();
|
|
150
|
+
const summary = { filesScanned: files.length, hits: 0, resolved: 0, constants: 0, marked: 0, allowlisted: 0 };
|
|
151
|
+
for (const abs of files) {
|
|
152
|
+
const rel = path.relative(pluginRoot, abs).split(path.sep).join('/');
|
|
153
|
+
if (/\.(test|spec)\.mjs$/.test(rel)) continue;
|
|
154
|
+
const lines = readFileSync(abs, 'utf8').split('\n');
|
|
155
|
+
const constNames = [];
|
|
156
|
+
const fileFindings = [];
|
|
157
|
+
let fileHits = 0;
|
|
158
|
+
lines.forEach((line, i) => {
|
|
159
|
+
if (isCommentLine(line) || !isHit(lines, i)) return;
|
|
160
|
+
fileHits += 1;
|
|
161
|
+
const decl = CONST_DECL_RE.exec(line);
|
|
162
|
+
if (decl) {
|
|
163
|
+
constNames.push(decl[1]);
|
|
164
|
+
summary.constants += 1;
|
|
165
|
+
} else if (covered(lines, i)) {
|
|
166
|
+
summary[RESOLVER_CALL_RE.test(statementWindow(lines, i)) ? 'resolved' : 'marked'] += 1;
|
|
167
|
+
} else {
|
|
168
|
+
fileFindings.push({ file: rel, line: i + 1, kind: 'unrouted', text: line.trim().slice(0, 160) });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
if (constNames.length > 0) {
|
|
172
|
+
const uses = new RegExp(`\\b(?:${constNames.join('|')})\\b`);
|
|
173
|
+
lines.forEach((line, i) => {
|
|
174
|
+
if (isCommentLine(line) || CONST_DECL_RE.test(line)) return;
|
|
175
|
+
if (!uses.test(line) || !PATH_FN_RE.test(line) || covered(lines, i)) return;
|
|
176
|
+
fileFindings.push({ file: rel, line: i + 1, kind: 'unrouted-constant', text: line.trim().slice(0, 160) });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (fileHits === 0) continue;
|
|
180
|
+
summary.hits += fileHits;
|
|
181
|
+
if (Object.hasOwn(allowlist, rel)) {
|
|
182
|
+
seenAllowlisted.add(rel);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
findings.push(...fileFindings);
|
|
186
|
+
}
|
|
187
|
+
for (const rel of Object.keys(allowlist)) {
|
|
188
|
+
if (!seenAllowlisted.has(rel)) findings.push({ file: rel, line: 0, kind: 'stale-allowlist' });
|
|
189
|
+
}
|
|
190
|
+
summary.allowlisted = seenAllowlisted.size;
|
|
191
|
+
return { ok: findings.length === 0, findings, summary };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} pluginRoot absolute plugin root
|
|
196
|
+
* @returns {number} 0 = clean, 1 = findings
|
|
197
|
+
*/
|
|
198
|
+
export function runCheckMetricsLedgerRoot(pluginRoot) {
|
|
199
|
+
console.log('--- Check: .orchestrator/metrics paths go through a ledger resolver (#1722) ---');
|
|
200
|
+
const inspection = scanMetricsLedgerRoot({ pluginRoot });
|
|
201
|
+
for (const f of inspection.findings) {
|
|
202
|
+
if (f.kind === 'stale-allowlist') {
|
|
203
|
+
console.log(` FAIL: ${f.file} is on the ALLOWLIST but builds no .orchestrator/metrics path any more — drop the entry`);
|
|
204
|
+
} else {
|
|
205
|
+
console.log(
|
|
206
|
+
` FAIL: ${f.file}:${f.line} builds a .orchestrator/metrics path without resolveLedgerPath() ` +
|
|
207
|
+
'(events/sessions: resolveEventsLedgerRoot()) — from a linked worktree it reads or writes a copy ' +
|
|
208
|
+
`lost with the worktree; route it, or mark a non-ledger line with \`// ledger-root: <reason>\`: ${f.text}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const s = inspection.summary;
|
|
213
|
+
if (inspection.ok) {
|
|
214
|
+
console.log(
|
|
215
|
+
` PASS: ${s.hits} metrics path line(s) in ${s.filesScanned} module(s): ${s.resolved} resolved, ` +
|
|
216
|
+
`${s.constants} relative constant(s), ${s.marked} marked, ${s.allowlisted} allowlisted module(s)`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
console.log('');
|
|
220
|
+
console.log(`Results: ${inspection.ok ? 1 : 0} passed, ${inspection.findings.length} failed`);
|
|
221
|
+
return inspection.ok ? 0 : 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (isMainModule(import.meta.url)) {
|
|
225
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--json');
|
|
226
|
+
const root = path.resolve(args[0] || process.cwd());
|
|
227
|
+
if (process.argv.includes('--json')) {
|
|
228
|
+
const inspection = scanMetricsLedgerRoot({ pluginRoot: root });
|
|
229
|
+
process.stdout.write(`${JSON.stringify(inspection, null, 2)}\n`);
|
|
230
|
+
process.exitCode = inspection.ok ? 0 : 1;
|
|
231
|
+
} else {
|
|
232
|
+
process.exitCode = runCheckMetricsLedgerRoot(root);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync, readdirSync, statSync, existsSync, realpathSync, lstatSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
|
3
|
+
import { readFileSync, readdirSync, statSync, existsSync, realpathSync, lstatSync, openSync, fstatSync, closeSync, readSync, constants } from 'node:fs';
|
|
4
4
|
import { join, extname, relative, basename, sep, resolve, dirname, isAbsolute } from 'node:path';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { argv } from 'node:process';
|
|
@@ -21,18 +21,75 @@ const isMain =
|
|
|
21
21
|
|
|
22
22
|
const pluginRoot = argv[2] ? canonicalPath(resolve(argv[2])) : argv[2];
|
|
23
23
|
const includeUntracked = argv.slice(3).includes('--include-untracked');
|
|
24
|
-
|
|
24
|
+
// --public-export (#1631): the root is a curated public snapshot. Adds the path check against
|
|
25
|
+
// the export policy and implies --require-owner-patterns (a public tree is never scanned blind).
|
|
26
|
+
const publicExport = argv.slice(3).includes('--public-export');
|
|
27
|
+
const requireOwnerPatterns = argv.slice(3).includes('--require-owner-patterns') || publicExport;
|
|
25
28
|
const packedIndex = argv.indexOf('--packed-files');
|
|
26
29
|
const packedInventoryPath = packedIndex >= 0 ? argv[packedIndex + 1] : null;
|
|
27
30
|
if (isMain && packedIndex >= 0 && (!packedInventoryPath || packedInventoryPath.startsWith('--'))) {
|
|
28
31
|
console.error('packed-files requires an inventory path'); process.exit(1);
|
|
29
32
|
}
|
|
30
33
|
if (isMain && !pluginRoot) {
|
|
31
|
-
console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]');
|
|
34
|
+
console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked] [--require-owner-patterns] [--public-export]');
|
|
32
35
|
process.exit(1);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
// Host policy stays outside source and package; this file remains standalone.
|
|
39
|
+
|
|
40
|
+
// Public-export policy (#1631, #1594). Lives here, not in scripts/lib/github-export.mjs,
|
|
41
|
+
// because vendored copies carry this file alone; the export CLI imports it from here.
|
|
42
|
+
export const PUBLIC_EXPORT_POLICY_FILE = '.orchestrator/policy/public-export.json';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate a public-export policy document.
|
|
46
|
+
* Each `exclude[].path` is an exact repo-relative file, or a directory prefix ending in
|
|
47
|
+
* `/` — no globs, no absolute paths, no `.`/`..` segments.
|
|
48
|
+
* @param {unknown} policy parsed JSON
|
|
49
|
+
* @returns {{ ok: true, excludes: string[] } | { ok: false, reason: string }}
|
|
50
|
+
*/
|
|
51
|
+
export function validatePublicExportPolicy(policy) {
|
|
52
|
+
if (!policy || typeof policy !== 'object' || Array.isArray(policy) || policy.version !== 1) return { ok: false, reason: 'policy must be an object with version 1' };
|
|
53
|
+
if (!Array.isArray(policy.exclude) || policy.exclude.length === 0) return { ok: false, reason: 'exclude must be a non-empty array' };
|
|
54
|
+
const excludes = [];
|
|
55
|
+
for (const entry of policy.exclude) {
|
|
56
|
+
const p = entry?.path;
|
|
57
|
+
if (typeof p !== 'string' || !p || p.startsWith('/') || /[*?[\]{}\\]/.test(p) || [...p].some((c) => c.charCodeAt(0) < 32)
|
|
58
|
+
|| p.replace(/\/$/, '').split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
59
|
+
return { ok: false, reason: 'invalid exclude path (exact file or dir prefix ending in "/", no globs)' };
|
|
60
|
+
}
|
|
61
|
+
excludes.push(p);
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, excludes };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read and validate `<root>/.orchestrator/policy/public-export.json`.
|
|
68
|
+
* @param {string} root
|
|
69
|
+
* @returns {{ ok: true, excludes: string[] } | { ok: false, reason: string }}
|
|
70
|
+
*/
|
|
71
|
+
export function loadPublicExportPolicy(root) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(readFileSync(join(root, PUBLIC_EXPORT_POLICY_FILE), 'utf8'));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { ok: false, reason: err?.code === 'ENOENT' ? `${PUBLIC_EXPORT_POLICY_FILE} missing` : `${PUBLIC_EXPORT_POLICY_FILE} unreadable or not JSON` };
|
|
77
|
+
}
|
|
78
|
+
return validatePublicExportPolicy(parsed);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True when a repo-relative path falls under a public-export exclude. A directory
|
|
83
|
+
* prefix matches only at a path boundary: `docs/audits/` excludes `docs/audits/x.md`
|
|
84
|
+
* but never the sibling `docs/auditsX.md`.
|
|
85
|
+
* @param {string} relPath forward-slash repo-relative path
|
|
86
|
+
* @param {string[]} excludes
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function isPublicExportExcluded(relPath, excludes) {
|
|
90
|
+
const norm = relPath.replace(/\\/g, '/');
|
|
91
|
+
return excludes.some((p) => (p.endsWith('/') ? norm.startsWith(p) : norm === p));
|
|
92
|
+
}
|
|
36
93
|
export const VAULT_CLEAR_SLUGS = new Set();
|
|
37
94
|
|
|
38
95
|
// Mirrors the zero-import private-config-dir resolver because vendored copies carry this file alone.
|
|
@@ -47,12 +104,24 @@ function policySource(env = process.env) {
|
|
|
47
104
|
const dir = own || (xdg ? join(xdg, 'session-orchestrator') : join(homedir(), '.config', 'session-orchestrator'));
|
|
48
105
|
return (env.SO_OWNER_PATTERNS_FILE || '').trim() || join(dir, 'owner-patterns.json');
|
|
49
106
|
}
|
|
107
|
+
// #1629: a refused DIRECTORY is a host-setup fault (umask 000 leaves the dir 0777), not a
|
|
108
|
+
// leak — it carries the dir so the one-time warning can name it instead of a bare CP1.
|
|
109
|
+
function unsafeDir(dir, st) {
|
|
110
|
+
return Object.assign(new Error('unsafe-dir'), { dir, why: st.isSymbolicLink() ? 'a symlink' : !st.isDirectory() ? 'not a directory' : `mode ${(st.mode & 0o777).toString(8)}, writable by group/others` });
|
|
111
|
+
}
|
|
50
112
|
function checkedHostFile(filename) {
|
|
51
113
|
if (!isAbsolute(filename)) throw new Error('unsafe');
|
|
114
|
+
// Windows has no POSIX mode bits: Node reports 0o666 (0o444 read-only) for every
|
|
115
|
+
// file and directory, so the group/other-write and 0600 checks below would refuse
|
|
116
|
+
// EVERY policy file there (measured on Windows 11, #1612). They apply on POSIX only;
|
|
117
|
+
// the symlink, hard-link, repo-ancestor and size checks still apply everywhere.
|
|
118
|
+
// Ceiling (BV-004): Windows ACLs are not inspected — revisit if the policy file
|
|
119
|
+
// ever carries secrets rather than private names.
|
|
120
|
+
const posixModes = process.platform !== 'win32';
|
|
52
121
|
// Canonical OS aliases (/tmp on macOS) are allowed above the private source parent.
|
|
53
122
|
let cursor = dirname(filename);
|
|
54
123
|
const parent = lstatSync(cursor);
|
|
55
|
-
if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o022)) throw
|
|
124
|
+
if (!parent.isDirectory() || parent.isSymbolicLink() || (posixModes && (parent.mode & 0o022))) throw unsafeDir(cursor, parent);
|
|
56
125
|
const canonicalParent = realpathSync(cursor);
|
|
57
126
|
// Reject repo-local data even if ignored; walk every ancestor for a .git marker.
|
|
58
127
|
cursor = canonicalParent;
|
|
@@ -64,27 +133,50 @@ function checkedHostFile(filename) {
|
|
|
64
133
|
cursor = dirname(filename);
|
|
65
134
|
while (cursor !== dirname(cursor)) {
|
|
66
135
|
const st = lstatSync(cursor);
|
|
67
|
-
if ((st.isSymbolicLink() && st.uid !== 0) || (st.uid !== 0 && (st.mode & 0o022))) throw
|
|
136
|
+
if ((st.isSymbolicLink() && st.uid !== 0) || (posixModes && st.uid !== 0 && (st.mode & 0o022))) throw unsafeDir(cursor, st);
|
|
68
137
|
cursor = dirname(cursor);
|
|
69
138
|
}
|
|
70
139
|
const st = lstatSync(filename);
|
|
71
|
-
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o7777) !== 0o600 || st.nlink !== 1 || (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw new Error('unsafe');
|
|
140
|
+
if (!st.isFile() || st.isSymbolicLink() || (posixModes && (st.mode & 0o7777) !== 0o600) || st.nlink !== 1 || (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw new Error('unsafe');
|
|
72
141
|
if (st.size > POLICY_LIMIT_BYTES) throw new Error('invalid');
|
|
73
142
|
const fd = openSync(filename, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
74
143
|
try {
|
|
75
144
|
const opened = fstatSync(fd);
|
|
76
|
-
if (!opened.isFile() || opened.ino !== st.ino || opened.dev !== st.dev || (opened.mode & 0o7777) !== 0o600 || opened.size > POLICY_LIMIT_BYTES) throw new Error('unsafe');
|
|
145
|
+
if (!opened.isFile() || opened.ino !== st.ino || opened.dev !== st.dev || (posixModes && (opened.mode & 0o7777) !== 0o600) || opened.size > POLICY_LIMIT_BYTES) throw new Error('unsafe');
|
|
77
146
|
const raw = readFileSync(fd, 'utf8');
|
|
78
147
|
if (Buffer.byteLength(raw) > POLICY_LIMIT_BYTES) throw new Error('invalid');
|
|
79
148
|
return raw;
|
|
80
149
|
} finally { closeSync(fd); }
|
|
81
150
|
}
|
|
151
|
+
// Redacted label for a refused directory: env-var name or `~`-relative, never the raw path
|
|
152
|
+
// (a custom SO_CONFIG_HOME may carry private names). Ancestors render as `<label>/..`.
|
|
153
|
+
function configDirLabel(dir, filename, env) {
|
|
154
|
+
const patterns = (env.SO_OWNER_PATTERNS_FILE || '').trim(), own = (env.SO_CONFIG_HOME || '').trim(), xdg = (env.XDG_CONFIG_HOME || '').trim();
|
|
155
|
+
const [base, label] = patterns ? [dirname(filename), 'dirname($SO_OWNER_PATTERNS_FILE)'] : own ? [own, '$SO_CONFIG_HOME'] : xdg ? [join(xdg, 'session-orchestrator'), '$XDG_CONFIG_HOME/session-orchestrator'] : [homedir(), '~'];
|
|
156
|
+
const rel = relative(base, dir);
|
|
157
|
+
if (!rel) return label;
|
|
158
|
+
return rel.split(sep).every((part) => part === '..') || label === '~' && !rel.startsWith('..') ? `${label}/${rel.split(sep).join('/')}` : 'an ancestor directory of the owner policy';
|
|
159
|
+
}
|
|
160
|
+
const warnedUnsafeDirs = new Set();
|
|
161
|
+
function warnUnsafeConfigDir(err, filename, env) {
|
|
162
|
+
if (warnedUnsafeDirs.has(err.dir)) return;
|
|
163
|
+
warnedUnsafeDirs.add(err.dir);
|
|
164
|
+
const label = configDirLabel(err.dir, filename, env);
|
|
165
|
+
// chmod cannot repair a symlink or a non-directory — name the fix that works.
|
|
166
|
+
const fix = err.why === 'a symlink' ? `replace the symlink ${label} with a real directory (mode 700)`
|
|
167
|
+
: err.why === 'not a directory' ? `replace ${label} with a real directory (mode 700)`
|
|
168
|
+
: err.dir === dirname(filename) ? `chmod 700 ${label}` : `chmod go-w ${label}`;
|
|
169
|
+
console.error(`WARN owner-patterns: unsafe config directory ${label} (${err.why}); owner policy not loaded, so every segment fails closed as CP1 — not a leak. Fix: ${fix}`);
|
|
170
|
+
}
|
|
82
171
|
export function inspectOwnerPatterns({ filePath, env = process.env, refresh = false } = {}) {
|
|
83
172
|
const filename = filePath ?? policySource(env);
|
|
84
173
|
if (!refresh && ownerCache?.filename === filename) return ownerCache.result;
|
|
85
174
|
let result;
|
|
86
175
|
try {
|
|
87
|
-
|
|
176
|
+
// #1613: Windows PowerShell 5.1 (`Set-Content -Encoding utf8`) writes a UTF-8 BOM,
|
|
177
|
+
// which JSON.parse rejects — strip it so such a policy is not read as invalid.
|
|
178
|
+
const raw = checkedHostFile(filename);
|
|
179
|
+
const policy = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
88
180
|
if (!policy || Array.isArray(policy) || policy.version !== 1 || Object.keys(policy).some((key) => key !== 'version' && !POLICY_KEYS.includes(key))) throw new Error('invalid');
|
|
89
181
|
for (const key of POLICY_KEYS) {
|
|
90
182
|
if (!Object.hasOwn(policy, key)) policy[key] = [];
|
|
@@ -94,7 +186,13 @@ export function inspectOwnerPatterns({ filePath, env = process.env, refresh = fa
|
|
|
94
186
|
if (!POLICY_KEYS.slice(0, 6).concat('personalNames').some((key) => policy[key].length)) throw new Error('invalid');
|
|
95
187
|
result = { status: 'ok', policy };
|
|
96
188
|
} catch (err) {
|
|
97
|
-
|
|
189
|
+
if (err.message === 'unsafe-dir') {
|
|
190
|
+
// Fail-closed unchanged: an unsafe directory never supplies (or waives) a policy.
|
|
191
|
+
result = { status: 'unsafe', reason: 'unsafe-config-dir' };
|
|
192
|
+
warnUnsafeConfigDir(err, filename, env);
|
|
193
|
+
} else {
|
|
194
|
+
result = { status: err.code === 'ENOENT' ? 'missing' : err.message === 'unsafe' || err.code === 'ELOOP' ? 'unsafe' : 'invalid', reason: err.code ?? (err.message === 'unsafe' ? 'unsafe-source' : 'invalid-source') };
|
|
195
|
+
}
|
|
98
196
|
}
|
|
99
197
|
ownerCache = {filename, result};
|
|
100
198
|
VAULT_CLEAR_SLUGS.clear();
|
|
@@ -394,9 +492,14 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
394
492
|
const TEXT_EXTS = new Set([
|
|
395
493
|
'.md',
|
|
396
494
|
'.mdx',
|
|
495
|
+
'.mdc',
|
|
397
496
|
'.mjs',
|
|
497
|
+
'.cjs',
|
|
398
498
|
'.js',
|
|
399
499
|
'.ts',
|
|
500
|
+
'.tsx',
|
|
501
|
+
'.py',
|
|
502
|
+
'.toml',
|
|
400
503
|
'.json',
|
|
401
504
|
'.jsonl',
|
|
402
505
|
'.yml',
|
|
@@ -411,11 +514,41 @@ const TEXT_EXTS = new Set([
|
|
|
411
514
|
|
|
412
515
|
const DOTFILE_ALLOWLIST = new Set(['.env.example', '.nvmrc', '.vault.yaml']);
|
|
413
516
|
|
|
517
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Cheap binary sniff for extensionless files (LICENSE, Dockerfile, .husky hooks,
|
|
521
|
+
* fixture launchers): a NUL byte in the first 8 KB means binary, the same
|
|
522
|
+
* heuristic git uses. An unreadable file counts as text so the scan loop
|
|
523
|
+
* reports it as "file unreadable" instead of skipping it silently.
|
|
524
|
+
* @param {string} filePath
|
|
525
|
+
* @returns {boolean}
|
|
526
|
+
*/
|
|
527
|
+
function looksLikeText(filePath) {
|
|
528
|
+
let fd;
|
|
529
|
+
try {
|
|
530
|
+
fd = openSync(filePath, 'r');
|
|
531
|
+
const buf = Buffer.alloc(BINARY_SNIFF_BYTES);
|
|
532
|
+
const n = readSync(fd, buf, 0, BINARY_SNIFF_BYTES, 0);
|
|
533
|
+
return !buf.subarray(0, n).includes(0);
|
|
534
|
+
} catch {
|
|
535
|
+
return true;
|
|
536
|
+
} finally {
|
|
537
|
+
if (fd !== undefined) closeSync(fd);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Formats that are binary by definition: never scanned, whatever their first bytes look like.
|
|
542
|
+
const BINARY_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.icns', '.pdf', '.woff', '.woff2', '.ttf', '.otf', '.eot', '.zip', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.mp3', '.mp4', '.mov', '.webm', '.wav', '.ogg', '.wasm']);
|
|
543
|
+
|
|
414
544
|
function isTextFile(filePath) {
|
|
415
545
|
const base = basename(filePath);
|
|
416
546
|
if (DOTFILE_ALLOWLIST.has(base)) return true;
|
|
417
547
|
const ext = extname(filePath);
|
|
418
|
-
|
|
548
|
+
// A listed extension is text; any other file (no extension, or an unknown one such as .tmpl,
|
|
549
|
+
// .template, .lock) is scanned unless it looks binary, so a new text format is never skipped.
|
|
550
|
+
if (TEXT_EXTS.has(ext)) return true;
|
|
551
|
+
return !BINARY_EXTS.has(ext.toLowerCase()) && looksLikeText(filePath);
|
|
419
552
|
}
|
|
420
553
|
|
|
421
554
|
function getTrackedFiles() {
|
|
@@ -534,7 +667,9 @@ const SELF_EXCLUSIONS = new Set([
|
|
|
534
667
|
const scanFiles = textFiles.filter((f) => {
|
|
535
668
|
const rel = relative(pluginRoot, f).replace(/\\/g, '/');
|
|
536
669
|
if (!packedInventoryPath && rel.startsWith('.orchestrator/audits/')) return false;
|
|
537
|
-
|
|
670
|
+
// --public-export scans everything that ships: these self-exclusions exist for the
|
|
671
|
+
// private repo's own CI, and a public snapshot must not carry their literals unscanned.
|
|
672
|
+
if (!packedInventoryPath && !publicExport && SELF_EXCLUSIONS.has(rel)) return false;
|
|
538
673
|
return true;
|
|
539
674
|
});
|
|
540
675
|
|
|
@@ -548,6 +683,19 @@ if (cp11.disabledReason) {
|
|
|
548
683
|
fail(`<scan-wide> — CP11 (guard disabled): ${cp11.disabledReason}`);
|
|
549
684
|
}
|
|
550
685
|
|
|
686
|
+
// --public-export: path-based, because a content pattern cannot recognise e.g. a
|
|
687
|
+
// preference card or an audit report. Runs over ALL tracked files, not only text files.
|
|
688
|
+
if (publicExport) {
|
|
689
|
+
const exportPolicy = loadPublicExportPolicy(pluginRoot);
|
|
690
|
+
if (!exportPolicy.ok) {
|
|
691
|
+
fail(`<scan-wide> — PX (public-export policy unavailable): ${exportPolicy.reason}`);
|
|
692
|
+
} else {
|
|
693
|
+
const leakedPaths = allFiles.map((f) => relative(pluginRoot, f).replace(/\\/g, '/')).filter((rel) => isPublicExportExcluded(rel, exportPolicy.excludes));
|
|
694
|
+
for (const rel of leakedPaths) fail(`${safeDiagnosticPath(rel, cp11Patterns)} — PX (path excluded from public export)`);
|
|
695
|
+
if (leakedPaths.length === 0) pass(`no public-export excluded path among ${allFiles.length} files`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
551
699
|
const violations = [];
|
|
552
700
|
|
|
553
701
|
for (const filePath of scanFiles) {
|