session-orchestrator 5.2.0 → 5.3.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/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* invariant for all exports below — see #554 A2.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
|
|
17
17
|
import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
|
|
@@ -1678,6 +1678,610 @@ function clockAgeMs(file, field, now) {
|
|
|
1678
1678
|
}
|
|
1679
1679
|
}
|
|
1680
1680
|
|
|
1681
|
+
// ---------------------------------------------------------------------------
|
|
1682
|
+
// Absolute-grant grading (#1398 cond. 4, #1405, #1406)
|
|
1683
|
+
// ---------------------------------------------------------------------------
|
|
1684
|
+
//
|
|
1685
|
+
// ONE grading predicate, TWO consumers (BV-003). It lived in
|
|
1686
|
+
// `scripts/validate-wave-scope.mjs` as four module-local helpers, where only the
|
|
1687
|
+
// pre-dispatch CLI could reach it — so `hooks/enforce-scope.mjs` Gate 5b honoured
|
|
1688
|
+
// grants the validator would have refused, and a manifest that never went through
|
|
1689
|
+
// the CLI was never graded at all. Both now call {@link gradeScopeEntry}:
|
|
1690
|
+
//
|
|
1691
|
+
// - the VALIDATOR turns an `error` verdict into exit 1 (pre-dispatch refusal),
|
|
1692
|
+
// passing an fs-backed `resolve` so the canonicalisation below is real;
|
|
1693
|
+
// - the HOOK keeps ALLOWING exactly what it allows today and emits ONE WARN
|
|
1694
|
+
// when the grant it just matched grades `error`. It passes NO resolver — see
|
|
1695
|
+
// the ceiling on `opts.resolve`.
|
|
1696
|
+
//
|
|
1697
|
+
// ## Why canonicalisation is the ROOT fix, not two more denylist strings (#1405)
|
|
1698
|
+
//
|
|
1699
|
+
// Gate 5b matches the REALPATH-RESOLVED write candidate (enforce-scope.mjs
|
|
1700
|
+
// SECURITY-REQ-03) against the raw allowedPaths entry. So the set of files a
|
|
1701
|
+
// grant reaches is decided by the CANONICAL spelling, while the validator graded
|
|
1702
|
+
// the LITERAL one. Measured on this host 2026-09-20 (`fs.realpathSync`):
|
|
1703
|
+
// `/etc` → `/private/etc`, `/var` → `/private/var`, `/tmp` → `/private/tmp`.
|
|
1704
|
+
// Two opposite defects followed from that one gap:
|
|
1705
|
+
//
|
|
1706
|
+
// - `/private/etc/**` and `/private/var/**` passed with a WARN although they
|
|
1707
|
+
// grant exactly what the denylisted `/etc/**` and `/var/**` grant, because
|
|
1708
|
+
// the denylist only ever saw the top segment `private`;
|
|
1709
|
+
// - `/tmp/x/**` passed and was advertised as "honoured by Gate 5b" although the
|
|
1710
|
+
// hook can never match it — the candidate arrives as `/private/tmp/x/a.md`
|
|
1711
|
+
// and `pathMatchesPattern('/private/tmp/x/a.md', '/tmp/x/**') === false`. A
|
|
1712
|
+
// grant that matches nothing is not a safe grant, it is a silent no-op that
|
|
1713
|
+
// reads as permission.
|
|
1714
|
+
//
|
|
1715
|
+
// Adding `private/etc` and `private/var` to the denylist would have fixed the
|
|
1716
|
+
// first pair on macOS only, and nothing at all of the second. Resolving the
|
|
1717
|
+
// grant's literal prefix the same direction the hook resolves candidates fixes
|
|
1718
|
+
// both, and degrades to today's behaviour wherever a prefix does not exist.
|
|
1719
|
+
//
|
|
1720
|
+
// ## Named ceilings (BV-004)
|
|
1721
|
+
//
|
|
1722
|
+
// - HOME grants (`/Users/…`, `/home/…`) are graded by SHAPE and RETURN before
|
|
1723
|
+
// canonicalisation. Deliberate: that is the one root where a legitimate
|
|
1724
|
+
// out-of-repo grant is routine, and the shape rule must not become
|
|
1725
|
+
// platform-dependent (on macOS `fs.realpathSync('/home')` is
|
|
1726
|
+
// `/System/Volumes/Data/home`, whose top segment is denylisted — grading a
|
|
1727
|
+
// Linux-home grant against that would refuse it on a macOS coordinator and
|
|
1728
|
+
// accept it on a Linux one). Revisit trigger: the first home grant Gate 5b
|
|
1729
|
+
// provably never matches because the home itself is a symlink.
|
|
1730
|
+
// - With NO `resolve` the grader is PURE and grades the literal spelling only.
|
|
1731
|
+
// NO PRODUCTION CALLER IS IN THAT SHAPE ANY MORE (#1398 cond. 4, closed
|
|
1732
|
+
// 2026-09-21): `hooks/enforce-scope.mjs` Gate 5b was the one resolver-free
|
|
1733
|
+
// caller, and it now passes {@link canonicalizeGrantPrefix} — the same
|
|
1734
|
+
// function `scripts/validate-wave-scope.mjs` passes — because the revisit
|
|
1735
|
+
// trigger below was MET. Measured A/B in one process, 200 repetitions,
|
|
1736
|
+
// median: the hook grades ONE grant per Gate 5b hit, +0.067 ms and 15
|
|
1737
|
+
// `realpathSync` calls, against a 5 ms decision threshold; the live 64-entry
|
|
1738
|
+
// manifest cost 0 syscalls (0 absolute entries — only an absolute grant
|
|
1739
|
+
// reaches the resolver at all). That closed the last verdict divergence,
|
|
1740
|
+
// `/tmp/x/**` (1 of the 9 #1398 probes), which no literal list could reach.
|
|
1741
|
+
// The resolver-free shape remains SUPPORTED and tested — it is what a
|
|
1742
|
+
// caller with no filesystem gets — and it keeps every literal-spelling
|
|
1743
|
+
// verdict (`/etc/**`, `/Users/<u>/.ssh/**`, `/Users/<u>/**`). Since #1418 it
|
|
1744
|
+
// ALSO reaches the first canonical-alias class, because the two spellings
|
|
1745
|
+
// macOS actually mints (`/private/etc`, `/private/var`) are on
|
|
1746
|
+
// DENIED_ABSOLUTE_ALIAS_ROOTS as literals. Those entries STAY: they cost
|
|
1747
|
+
// zero syscalls and they are the only thing standing between a
|
|
1748
|
+
// hypothetical future resolver-free caller and a hole on this platform
|
|
1749
|
+
// (measured 2026-09-20 @ 7e110a2a). What such a caller still cannot reach is
|
|
1750
|
+
// the SECOND class, `non-canonical` (`/tmp/x/**`) — no literal list
|
|
1751
|
+
// substitutes for the realpath call — which is why the hook now makes it.
|
|
1752
|
+
// Revisit trigger: a caller that CANNOT supply a resolver (no filesystem, or
|
|
1753
|
+
// a hot path measured expensive on its own host) — it is back to the
|
|
1754
|
+
// residual above, one missing NOTICE, never a wider allow.
|
|
1755
|
+
// ---------------------------------------------------------------------------
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* Well-known top-level system directories a wave scope may never grant.
|
|
1759
|
+
*
|
|
1760
|
+
* A FIXED DENYLIST, not a segment-count threshold: the #792 legitimate grant
|
|
1761
|
+
* (`/private/tmp/<session>/scratchpad/**`) is itself a shallow grant under an
|
|
1762
|
+
* unusual root ("private"), so any depth heuristic tight enough to catch
|
|
1763
|
+
* `/etc/**` would catch that too.
|
|
1764
|
+
*
|
|
1765
|
+
* `Users` and `home` are deliberately ABSENT (#1398/#1402): they are the only two
|
|
1766
|
+
* roots under which a legitimate grant is routine, and denying them flat made the
|
|
1767
|
+
* validator contradict the hook it validates for. {@link classifyHomeGrant}
|
|
1768
|
+
* grades them by shape instead.
|
|
1769
|
+
* @type {ReadonlyArray<string>}
|
|
1770
|
+
*/
|
|
1771
|
+
const DENIED_ABSOLUTE_TOP_SEGMENTS = Object.freeze([
|
|
1772
|
+
'etc',
|
|
1773
|
+
'root',
|
|
1774
|
+
'bin',
|
|
1775
|
+
'sbin',
|
|
1776
|
+
'usr',
|
|
1777
|
+
'System',
|
|
1778
|
+
'var',
|
|
1779
|
+
'boot',
|
|
1780
|
+
'dev',
|
|
1781
|
+
'proc',
|
|
1782
|
+
'sys',
|
|
1783
|
+
'Library',
|
|
1784
|
+
'Applications',
|
|
1785
|
+
'Windows',
|
|
1786
|
+
]);
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* macOS symlink ALIASES of the denylisted roots above, spelled literally.
|
|
1790
|
+
*
|
|
1791
|
+
* `fs.realpathSync('/etc')` is `/private/etc` and `/var` → `/private/var` on
|
|
1792
|
+
* this platform, and Gate 5b matches the REALPATH-RESOLVED write candidate — so
|
|
1793
|
+
* `/private/etc/**` is the spelling that actually reaches everything under
|
|
1794
|
+
* `/etc`, while `/etc/**` reaches nothing there. {@link gradeScopeEntry}'s
|
|
1795
|
+
* canonical rung catches both, but ONLY when a resolver is passed, and
|
|
1796
|
+
* `hooks/enforce-scope.mjs` passes none (PreToolUse hot path). Listing the two
|
|
1797
|
+
* aliases literally gives the resolver-free caller the same verdict at ZERO
|
|
1798
|
+
* syscalls. Measured 2026-09-20 @ 7e110a2a, before: hook `warn/absolute` vs.
|
|
1799
|
+
* validator `error/denied-system-dir` for both entries — the feature was inert
|
|
1800
|
+
* on the only platform that mints these spellings.
|
|
1801
|
+
*
|
|
1802
|
+
* `private/tmp` is deliberately ABSENT, and the omission is load-bearing: `tmp`
|
|
1803
|
+
* is not on the denylist either, because `/private/tmp/<session>/scratchpad/**`
|
|
1804
|
+
* is the #792 SANCTIONED grant. Aliasing it would refuse the one out-of-repo
|
|
1805
|
+
* grant this file exists to permit.
|
|
1806
|
+
*
|
|
1807
|
+
* Named ceiling (BV-004): a literal list covers the two aliases this platform
|
|
1808
|
+
* mints and nothing else — a host whose `/usr` is a symlink still needs the
|
|
1809
|
+
* resolver to be graded. Revisit trigger: the first denylisted root that gains
|
|
1810
|
+
* a symlink alias on any platform the fleet runs on.
|
|
1811
|
+
* @type {ReadonlyArray<string>}
|
|
1812
|
+
*/
|
|
1813
|
+
const DENIED_ABSOLUTE_ALIAS_ROOTS = Object.freeze(['private/etc', 'private/var']);
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Case-fold a path or path segment for comparison against the sets below.
|
|
1817
|
+
*
|
|
1818
|
+
* Every comparison here MUST go through this: the classification is a proxy for
|
|
1819
|
+
* what the FILESYSTEM will resolve, and the two default filesystems this repo
|
|
1820
|
+
* runs on (APFS on macOS, NTFS on Windows) are case-INSENSITIVE while neither
|
|
1821
|
+
* `path.posix.normalize` nor `fs.realpath()` corrects the spelling. Measured
|
|
1822
|
+
* 2026-09-19: `/Users/<u>/.ssh` and `/users/<u>/.ssh` report the SAME inode, as
|
|
1823
|
+
* do `/Users/<u>/Library/Keychains` and `/users/<u>/library/Keychains`.
|
|
1824
|
+
*
|
|
1825
|
+
* `'en-US'` is pinned explicitly: the host locale must not decide a security
|
|
1826
|
+
* verdict (a Turkish default locale folds `I` to `ı`, taking `LIBRARY` out of
|
|
1827
|
+
* the match).
|
|
1828
|
+
*
|
|
1829
|
+
* Named ceiling (BV-004): folding is strictly STRICTER on a case-SENSITIVE
|
|
1830
|
+
* filesystem, where `/library` really is a different directory from `/Library`.
|
|
1831
|
+
* Revisit trigger: the first legitimate grant refused for that reason.
|
|
1832
|
+
*
|
|
1833
|
+
* @param {string} value
|
|
1834
|
+
* @returns {string}
|
|
1835
|
+
*/
|
|
1836
|
+
function foldPath(value) {
|
|
1837
|
+
return value.toLocaleLowerCase('en-US');
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* A root followed by a separator, used to split a root-wide grant from its tail.
|
|
1842
|
+
* The optional drive designator is what makes this independent of
|
|
1843
|
+
* `path.isAbsolute()`, which is platform-native: on a POSIX host it never
|
|
1844
|
+
* reports `C:\…` as absolute, so a Windows root grant would slip past ungraded.
|
|
1845
|
+
*/
|
|
1846
|
+
const FILESYSTEM_ROOT_PREFIX_RE = /^(?:[A-Za-z]:)?[\\/]+/;
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* Does this entry grant the WHOLE filesystem — the bare root, or any spelling
|
|
1850
|
+
* that differs from it only by wildcard segments?
|
|
1851
|
+
*
|
|
1852
|
+
* The literal-only predecessor was the wrong way round (measured 2026-09-20 @
|
|
1853
|
+
* 7e110a2a): `gradeScopeEntry('/')` → `error/filesystem-root` and `/etc/**` →
|
|
1854
|
+
* `error/denied-system-dir`, while `/**` — which `pathMatchesPattern` matches
|
|
1855
|
+
* for EVERY path at Gate 5b, i.e. strictly wider than both — graded
|
|
1856
|
+
* `warn/absolute`. The check that refused the root was literal; the thing that
|
|
1857
|
+
* granted the root was a glob.
|
|
1858
|
+
*
|
|
1859
|
+
* NOT root-wide, and deliberately so: any entry naming a literal segment after
|
|
1860
|
+
* the root (`/etc/**`, `/Users/<u>/**`, or a leading `**` followed by a literal
|
|
1861
|
+
* segment). Those are deep globs and keep their own, narrower verdicts.
|
|
1862
|
+
*
|
|
1863
|
+
* @param {string} entry
|
|
1864
|
+
* @returns {boolean}
|
|
1865
|
+
*/
|
|
1866
|
+
function isFilesystemRootGrant(entry) {
|
|
1867
|
+
const root = FILESYSTEM_ROOT_PREFIX_RE.exec(entry);
|
|
1868
|
+
if (root === null) return false;
|
|
1869
|
+
const tail = entry.slice(root[0].length);
|
|
1870
|
+
if (tail.length === 0) return true;
|
|
1871
|
+
// An empty segment comes from a doubled or trailing separator ("//**", "/**/"),
|
|
1872
|
+
// which widens nothing; any other non-wildcard segment narrows the grant.
|
|
1873
|
+
return tail.split(/[\\/]+/).every((seg) => seg === '' || seg === '*' || seg === '**');
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* Top-level segments that open a HOME directory rather than a system one —
|
|
1878
|
+
* macOS (`/Users/<user>`) and Linux (`/home/<user>`). Stored FOLDED.
|
|
1879
|
+
* @type {ReadonlySet<string>}
|
|
1880
|
+
*/
|
|
1881
|
+
const HOME_TOP_SEGMENTS = new Set(['users', 'home']);
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* How many LITERAL segments a home grant must name before its first wildcard:
|
|
1885
|
+
* `<home-root>` / `<user>` / `<one directory>` = 3. Two is the bare home itself,
|
|
1886
|
+
* and a wildcard inside those three widens the grant to a whole home.
|
|
1887
|
+
*/
|
|
1888
|
+
const HOME_MIN_LITERAL_SEGMENTS = 3;
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* The ONE dot-directory carve-out (#1406) and the depth that buys it.
|
|
1892
|
+
*
|
|
1893
|
+
* `~/.cache/<project>/…` is where a study contract keeps its data and where the
|
|
1894
|
+
* hook honours the path today; `~/.cache/**` itself is not — it is every tool's
|
|
1895
|
+
* cache on the host, including credential caches. Four literal segments
|
|
1896
|
+
* (`/Users` / `<user>` / `.cache` / `<project>`) is the narrowest rule that
|
|
1897
|
+
* separates the two. Every OTHER dot-directory and `Library` stay refused at any
|
|
1898
|
+
* depth: `.ssh`, `.aws`, `.gnupg`, `.config`, `.claude`, `.codex` carry
|
|
1899
|
+
* credentials or the agent guards themselves at their FIRST level, so no depth
|
|
1900
|
+
* makes them safe.
|
|
1901
|
+
*/
|
|
1902
|
+
const CACHE_CARVEOUT_SEGMENT = '.cache';
|
|
1903
|
+
const CACHE_CARVEOUT_MIN_LITERAL_SEGMENTS = 4;
|
|
1904
|
+
|
|
1905
|
+
/**
|
|
1906
|
+
* Home subdirectories that carry credentials, tokens, or the agent guards
|
|
1907
|
+
* themselves. ONE predicate rather than a list, because the list is open-ended
|
|
1908
|
+
* and every omission is a live credential store: any dot-prefixed directory
|
|
1909
|
+
* (`.ssh`, `.aws`, `.gnupg`, `.config` — which holds tokens AND `owner.yaml` —
|
|
1910
|
+
* `.claude`, `.codex`, `.cursor`, `.docker`, `.npmrc`, `.netrc`) plus macOS
|
|
1911
|
+
* `Library` (whose `Keychains` subtree carries no leading dot).
|
|
1912
|
+
* @param {string} segment
|
|
1913
|
+
* @returns {boolean}
|
|
1914
|
+
*/
|
|
1915
|
+
function isSensitiveHomeSegment(segment) {
|
|
1916
|
+
return segment.startsWith('.') || foldPath(segment) === 'library';
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Is `candidate` the directory `root` itself, or below it? Fold-compared, and
|
|
1921
|
+
* anchored on a `/` boundary so `/system-backup` is not read as being under
|
|
1922
|
+
* `/system`.
|
|
1923
|
+
* @param {string} candidate
|
|
1924
|
+
* @param {string} root
|
|
1925
|
+
* @returns {boolean}
|
|
1926
|
+
*/
|
|
1927
|
+
function isUnderRoot(candidate, root) {
|
|
1928
|
+
const c = foldPath(candidate);
|
|
1929
|
+
const r = foldPath(root);
|
|
1930
|
+
if (r === '/') return c.startsWith('/');
|
|
1931
|
+
return c === r || c.startsWith(`${r}/`);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* The denylisted system directory this prefix sits under, or `null`.
|
|
1936
|
+
*
|
|
1937
|
+
* `resolveRoot` is applied to each denied root before the comparison, so the
|
|
1938
|
+
* CANONICAL pass catches `/private/etc` (= `realpath('/etc')`) without the
|
|
1939
|
+
* denylist having to spell out a second, platform-specific alias for every
|
|
1940
|
+
* entry. With the identity resolver this is the plain literal check.
|
|
1941
|
+
*
|
|
1942
|
+
* The {@link DENIED_ABSOLUTE_ALIAS_ROOTS} pass is NOT routed through
|
|
1943
|
+
* `resolveRoot`: those entries are already the canonical spelling, so resolving
|
|
1944
|
+
* them buys nothing and would spend the syscall the resolver-free caller exists
|
|
1945
|
+
* to avoid.
|
|
1946
|
+
*
|
|
1947
|
+
* @param {string} prefix — an absolute, `.`-normalized literal path prefix
|
|
1948
|
+
* @param {(p: string) => string} resolveRoot
|
|
1949
|
+
* @returns {string|null} the denylisted root (canonical casing, no leading `/`)
|
|
1950
|
+
* that matched
|
|
1951
|
+
*/
|
|
1952
|
+
function deniedRootFor(prefix, resolveRoot) {
|
|
1953
|
+
for (const segment of DENIED_ABSOLUTE_TOP_SEGMENTS) {
|
|
1954
|
+
if (isUnderRoot(prefix, resolveRoot(`/${segment}`))) return segment;
|
|
1955
|
+
}
|
|
1956
|
+
for (const alias of DENIED_ABSOLUTE_ALIAS_ROOTS) {
|
|
1957
|
+
if (isUnderRoot(prefix, `/${alias}`)) return alias;
|
|
1958
|
+
}
|
|
1959
|
+
return null;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* The LITERAL prefix of an absolute grant — everything before its first segment
|
|
1964
|
+
* carrying a `*` (`/private/tmp/x/**` → `/private/tmp/x`, `/Users/*\/p/**` →
|
|
1965
|
+
* `/Users`, `/etc/passwd` → `/etc/passwd`). `/` when the first segment already
|
|
1966
|
+
* globs. This, never the raw entry, is what gets canonicalised: the wildcard
|
|
1967
|
+
* part has no filesystem identity.
|
|
1968
|
+
* @param {string} normalizedEntry
|
|
1969
|
+
* @returns {string}
|
|
1970
|
+
*/
|
|
1971
|
+
function literalGrantPrefix(normalizedEntry) {
|
|
1972
|
+
const kept = [];
|
|
1973
|
+
for (const segment of normalizedEntry.split('/')) {
|
|
1974
|
+
if (segment.includes('*')) break;
|
|
1975
|
+
kept.push(segment);
|
|
1976
|
+
}
|
|
1977
|
+
const prefix = kept.join('/');
|
|
1978
|
+
return prefix.length === 0 ? '/' : prefix;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* Grade an absolute entry whose top-level segment is a home root.
|
|
1983
|
+
*
|
|
1984
|
+
* The rule is SHAPE-based, not identity-based, because the hook grants by shape:
|
|
1985
|
+
* Gate 5b feeds the entry straight to {@link pathMatchesPattern}, where a
|
|
1986
|
+
* wildcard in an early segment silently widens the grant. Measured 2026-09-19:
|
|
1987
|
+
* `/Users/alice/**` matches `/Users/alice/.ssh/authorized_keys`, and a bare `*`
|
|
1988
|
+
* as the third segment matches `/Users/alice/.ssh/id` — a wildcard there reaches
|
|
1989
|
+
* dot-directories. So the depth is counted over the LITERAL prefix, never over
|
|
1990
|
+
* the raw segment count.
|
|
1991
|
+
*
|
|
1992
|
+
* Named ceiling (BV-004): only the FIRST directory below the home is checked for
|
|
1993
|
+
* sensitivity. A dot-directory DEEPER in the tree (`…/vault/.git/hooks/**`) stays
|
|
1994
|
+
* ungated, because at that depth the grant is already scoped to one project the
|
|
1995
|
+
* operator named explicitly. Revisit trigger: the first time a wave legitimately
|
|
1996
|
+
* needs a grant INTO a foreign repo.
|
|
1997
|
+
*
|
|
1998
|
+
* @param {string} normalizedEntry — the entry with `.` segments collapsed, so a
|
|
1999
|
+
* `/Users/./alice/.ssh` cannot shift the literal prefix and hide a
|
|
2000
|
+
* dot-directory at index 2.
|
|
2001
|
+
* @returns {{verdict: 'error'|'warn', reason: string}|null} null when the entry
|
|
2002
|
+
* is not a home grant at all.
|
|
2003
|
+
*/
|
|
2004
|
+
function classifyHomeGrant(normalizedEntry) {
|
|
2005
|
+
const segments = normalizedEntry.split('/').filter(Boolean);
|
|
2006
|
+
if (segments.length === 0 || !HOME_TOP_SEGMENTS.has(foldPath(segments[0]))) return null;
|
|
2007
|
+
|
|
2008
|
+
/** Literal prefix: the segments before the first one carrying a wildcard. */
|
|
2009
|
+
const literal = [];
|
|
2010
|
+
for (const segment of segments) {
|
|
2011
|
+
if (segment.includes('*')) break;
|
|
2012
|
+
literal.push(segment);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
if (literal.length < HOME_MIN_LITERAL_SEGMENTS) {
|
|
2016
|
+
return {
|
|
2017
|
+
verdict: 'error',
|
|
2018
|
+
reason:
|
|
2019
|
+
`grants a home directory at or above the user level — a home grant must name at least ` +
|
|
2020
|
+
`${HOME_MIN_LITERAL_SEGMENTS} literal path segments before its first wildcard ` +
|
|
2021
|
+
`(e.g. /Users/<user>/<project>/**); this one names ${literal.length}, so Gate 5b would ` +
|
|
2022
|
+
`honour it across the whole home (measured: /Users/<user>/** matches ~/.ssh/authorized_keys)`,
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
const firstBelowHome = literal[HOME_MIN_LITERAL_SEGMENTS - 1];
|
|
2027
|
+
// #1406 — the one carve-out, and it is a DEPTH rule, not a name on a list:
|
|
2028
|
+
// `~/.cache/**` stays refused, `~/.cache/<project>/…` is honoured.
|
|
2029
|
+
const cacheCarveout =
|
|
2030
|
+
foldPath(firstBelowHome) === CACHE_CARVEOUT_SEGMENT &&
|
|
2031
|
+
literal.length >= CACHE_CARVEOUT_MIN_LITERAL_SEGMENTS;
|
|
2032
|
+
if (!cacheCarveout && isSensitiveHomeSegment(firstBelowHome)) {
|
|
2033
|
+
return {
|
|
2034
|
+
verdict: 'error',
|
|
2035
|
+
reason:
|
|
2036
|
+
`grants a sensitive home subdirectory ("${firstBelowHome}") — dot-directories and Library ` +
|
|
2037
|
+
`hold credentials, tokens, owner.yaml and the agent guards themselves; scope a project ` +
|
|
2038
|
+
`directory instead (the sole exception is ~/.cache/<project>/…, which must name at least ` +
|
|
2039
|
+
`${CACHE_CARVEOUT_MIN_LITERAL_SEGMENTS} literal segments — #1406)`,
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
return { verdict: 'warn', reason: 'home-directory grant honoured by Gate 5b' };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* One process resolves the same ~14 denylist roots for every absolute grant it
|
|
2048
|
+
* grades; the cache keeps that at one `realpathSync` per distinct path.
|
|
2049
|
+
*
|
|
2050
|
+
* Named ceiling (BV-004): process-lifetime, never invalidated. Both real callers
|
|
2051
|
+
* are one-shot processes (a PreToolUse hook, a CLI run), where a mount changing
|
|
2052
|
+
* mid-process is not a case. Revisit trigger: the first long-running consumer —
|
|
2053
|
+
* it needs a fresh map per call, not this one.
|
|
2054
|
+
* @type {Map<string, string>}
|
|
2055
|
+
*/
|
|
2056
|
+
const CANONICAL_PREFIX_CACHE = new Map();
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* Memoized canonicaliser for the LITERAL prefix of an absolute grant — the
|
|
2060
|
+
* `resolve` injection {@link gradeScopeEntry} needs (#1405).
|
|
2061
|
+
*
|
|
2062
|
+
* THE DIRECTION MATTERS: `hooks/enforce-scope.mjs` Gate 5b matches the
|
|
2063
|
+
* REALPATH-RESOLVED write candidate (SECURITY-REQ-03) against the raw
|
|
2064
|
+
* allowedPaths entry, so the files a grant actually reaches are decided by the
|
|
2065
|
+
* canonical spelling. Grading the literal one let `/private/etc/**` pass while
|
|
2066
|
+
* `/etc/**` was refused (same directory on macOS — `realpath('/etc')` is
|
|
2067
|
+
* `/private/etc`), and let `/tmp/x/**` pass while the hook could never match it.
|
|
2068
|
+
* Resolving HERE, the same direction the hook resolves candidates, is the root
|
|
2069
|
+
* fix; adding two more denylist strings would have been neither.
|
|
2070
|
+
*
|
|
2071
|
+
* WHY IT LIVES IN THE HOOK-SAFE MODULE (#1398 cond. 4). It was kept in the CLI
|
|
2072
|
+
* layer on the assumption that ~14 `realpathSync` calls are too expensive for a
|
|
2073
|
+
* PreToolUse hot path. Measured 2026-09-21 on this host (A/B in one process, 200
|
|
2074
|
+
* repetitions, median) that assumption does not hold, and the split it bought
|
|
2075
|
+
* was a live verdict divergence:
|
|
2076
|
+
* - the hook grades exactly ONE grant per Gate 5b hit (the entry that matched
|
|
2077
|
+
* the write candidate), not the whole manifest: **+0.067 ms**, 15
|
|
2078
|
+
* `realpathSync` calls — 75× under the 5 ms decision threshold;
|
|
2079
|
+
* - the whole 9-grant probe set of #1398: **+0.154 ms**;
|
|
2080
|
+
* - the live 64-entry manifest of this session: **0** `realpathSync` calls,
|
|
2081
|
+
* because 0 of its entries are absolute. Only an absolute grant reaches the
|
|
2082
|
+
* resolver at all, and Gate 5b only runs for an out-of-repo write.
|
|
2083
|
+
* The module header's "no I/O at import time" is untouched: this is I/O inside a
|
|
2084
|
+
* function, exactly as {@link findScopeFile} already does with `existsSync`.
|
|
2085
|
+
*
|
|
2086
|
+
* PURE ENOUGH FOR LINUX CI: a prefix that does not exist never throws — the walk
|
|
2087
|
+
* climbs to the nearest existing ancestor and re-attaches the missing suffix (the
|
|
2088
|
+
* same strategy `hooks/enforce-scope.mjs` uses for a Write to a not-yet-existing
|
|
2089
|
+
* file), and a wholly unresolvable path returns the input unchanged. On Linux
|
|
2090
|
+
* `/etc` and `/var` are not symlinks, so the canonical pass is a no-op there and
|
|
2091
|
+
* the literal verdicts carry the whole load.
|
|
2092
|
+
*
|
|
2093
|
+
* @param {string} absPath
|
|
2094
|
+
* @returns {string}
|
|
2095
|
+
*/
|
|
2096
|
+
export function canonicalizeGrantPrefix(absPath) {
|
|
2097
|
+
const cached = CANONICAL_PREFIX_CACHE.get(absPath);
|
|
2098
|
+
if (cached !== undefined) return cached;
|
|
2099
|
+
|
|
2100
|
+
// Assigned on BOTH loop exits (resolved, or nothing on this branch resolves);
|
|
2101
|
+
// an initializer here would be dead — see the eslint `no-useless-assignment`
|
|
2102
|
+
// rule, which is on in this repo.
|
|
2103
|
+
let result;
|
|
2104
|
+
let current = absPath;
|
|
2105
|
+
const missing = [];
|
|
2106
|
+
for (;;) {
|
|
2107
|
+
try {
|
|
2108
|
+
const real = realpathSync(current).split(path.sep).join('/');
|
|
2109
|
+
const suffix = [...missing].reverse().join('/');
|
|
2110
|
+
result = suffix.length === 0 ? real : `${real === '/' ? '' : real}/${suffix}`;
|
|
2111
|
+
break;
|
|
2112
|
+
} catch {
|
|
2113
|
+
const parent = path.posix.dirname(current);
|
|
2114
|
+
if (parent === current) {
|
|
2115
|
+
result = absPath; // nothing on this branch resolves — keep the literal
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
missing.push(path.posix.basename(current));
|
|
2119
|
+
current = parent;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
CANONICAL_PREFIX_CACHE.set(absPath, result);
|
|
2124
|
+
return result;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* Grade ONE `allowedPaths` / fileScope entry as a Gate 5b grant.
|
|
2129
|
+
*
|
|
2130
|
+
* The single grading predicate shared by `scripts/validate-wave-scope.mjs`
|
|
2131
|
+
* (refuses `error` before dispatch) and `hooks/enforce-scope.mjs` Gate 5b (WARNs
|
|
2132
|
+
* on `error`, never denies). See the section block above for the design.
|
|
2133
|
+
*
|
|
2134
|
+
* Verdict order — each rung is load-bearing:
|
|
2135
|
+
* 1. a tilde entry (`~/…`) — NOTHING in the scope chain expands it (#1405.3),
|
|
2136
|
+
* so it grants nothing while reading like a grant;
|
|
2137
|
+
* 2. a filesystem-root grant — the bare root, where `pathMatchesPattern(p, '/')`
|
|
2138
|
+
* is `true` for every path (the `dir/` prefix branch), AND every spelling
|
|
2139
|
+
* that adds only wildcards to it (`/**`, `/*`, `C:\**`), which reaches the
|
|
2140
|
+
* same set through the glob branch;
|
|
2141
|
+
* 3. non-absolute entries are not ours to grade → `null`;
|
|
2142
|
+
* 4. the denylisted system roots, LITERAL spelling;
|
|
2143
|
+
* 5. home grants, by shape — these RETURN, see the ceiling above;
|
|
2144
|
+
* 6. the denylisted system roots, CANONICAL spelling (#1405.1);
|
|
2145
|
+
* 7. a literal prefix that is not canonical — Gate 5b can never match it
|
|
2146
|
+
* (#1405.2);
|
|
2147
|
+
* 8. otherwise a WARN, split only so the narrowest shape (a concrete absolute
|
|
2148
|
+
* file) says so.
|
|
2149
|
+
*
|
|
2150
|
+
* Pure with no `opts.resolve`; never throws (a throwing resolver degrades to the
|
|
2151
|
+
* literal spelling). Returns `null` for every ordinary relative entry.
|
|
2152
|
+
*
|
|
2153
|
+
* @param {string} entry
|
|
2154
|
+
* @param {{resolve?: (absPath: string) => string}} [opts] — canonicaliser for an
|
|
2155
|
+
* absolute path; MUST tolerate a non-existent path (resolve the nearest
|
|
2156
|
+
* existing ancestor, or return the input unchanged).
|
|
2157
|
+
* @returns {{verdict: 'error'|'warn', code: string, message: string}|null}
|
|
2158
|
+
*/
|
|
2159
|
+
export function gradeScopeEntry(entry, opts = {}) {
|
|
2160
|
+
if (typeof entry !== 'string' || entry.length === 0) return null;
|
|
2161
|
+
const bag = opts !== null && typeof opts === 'object' ? opts : {};
|
|
2162
|
+
const resolver = typeof bag.resolve === 'function' ? bag.resolve : null;
|
|
2163
|
+
/** @param {string} p @returns {string} */
|
|
2164
|
+
const canonicalize = (p) => {
|
|
2165
|
+
if (resolver === null) return p;
|
|
2166
|
+
try {
|
|
2167
|
+
const resolved = resolver(p);
|
|
2168
|
+
return typeof resolved === 'string' && resolved.length > 0 ? resolved : p;
|
|
2169
|
+
} catch {
|
|
2170
|
+
return p;
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
if (entry === '~' || entry.startsWith('~/')) {
|
|
2175
|
+
return {
|
|
2176
|
+
verdict: 'error',
|
|
2177
|
+
code: 'tilde',
|
|
2178
|
+
message:
|
|
2179
|
+
`contains a tilde path: ${entry} — the tilde is NOT expanded by hooks/enforce-scope.mjs ` +
|
|
2180
|
+
`Gate 5b, nor anywhere else in the scope chain, so this entry matches nothing while ` +
|
|
2181
|
+
`reading like a grant; write the expanded absolute path instead`,
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
if (isFilesystemRootGrant(entry)) {
|
|
2186
|
+
return {
|
|
2187
|
+
verdict: 'error',
|
|
2188
|
+
code: 'filesystem-root',
|
|
2189
|
+
message:
|
|
2190
|
+
`grants the entire filesystem root: ${entry} — refused unconditionally, this can never ` +
|
|
2191
|
+
`be a valid wave scope (a root followed only by wildcards reaches every path Gate 5b ` +
|
|
2192
|
+
`can see, exactly as the bare root does)`,
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
if (!path.isAbsolute(entry)) return null;
|
|
2197
|
+
|
|
2198
|
+
// `.` segments are collapsed BEFORE any classification (#1398): without it,
|
|
2199
|
+
// `/Users/./alice/.ssh/**` has a literal prefix whose third element is
|
|
2200
|
+
// "alice", so the dot-directory sits at index 3 and the sensitivity check
|
|
2201
|
+
// misses it. `..` needs no handling — the caller's traversal check rejects
|
|
2202
|
+
// any entry containing `../`, whatever else it is.
|
|
2203
|
+
const normalized = path.posix.normalize(entry);
|
|
2204
|
+
const literalPrefix = literalGrantPrefix(normalized);
|
|
2205
|
+
|
|
2206
|
+
const deniedLiteral = deniedRootFor(literalPrefix, (p) => p);
|
|
2207
|
+
if (deniedLiteral !== null) {
|
|
2208
|
+
return {
|
|
2209
|
+
verdict: 'error',
|
|
2210
|
+
code: 'denied-system-dir',
|
|
2211
|
+
message:
|
|
2212
|
+
`contains a well-known system/home directory grant: ${entry} ` +
|
|
2213
|
+
`(denylisted system root "/${deniedLiteral}") — refused, scope a narrower path instead`,
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
const home = classifyHomeGrant(normalized);
|
|
2218
|
+
if (home !== null) {
|
|
2219
|
+
return home.verdict === 'error'
|
|
2220
|
+
? { verdict: 'error', code: 'home-grant', message: `${home.reason}: ${entry}` }
|
|
2221
|
+
: {
|
|
2222
|
+
verdict: 'warn',
|
|
2223
|
+
code: 'home-grant',
|
|
2224
|
+
message:
|
|
2225
|
+
`contains an absolute (out-of-repo) path: ${entry} — ${home.reason}; ` +
|
|
2226
|
+
`verify this grant is intentional`,
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
const canonicalPrefix = canonicalize(literalPrefix);
|
|
2231
|
+
|
|
2232
|
+
const deniedCanonical = deniedRootFor(canonicalPrefix, canonicalize);
|
|
2233
|
+
if (deniedCanonical !== null) {
|
|
2234
|
+
return {
|
|
2235
|
+
verdict: 'error',
|
|
2236
|
+
code: 'denied-system-dir',
|
|
2237
|
+
message:
|
|
2238
|
+
`contains a well-known system/home directory grant: ${entry} — its literal prefix ` +
|
|
2239
|
+
`"${literalPrefix}" resolves to "${canonicalPrefix}", inside the denylisted system ` +
|
|
2240
|
+
`directory "${deniedCanonical}"; Gate 5b matches the REALPATH-resolved write candidate, ` +
|
|
2241
|
+
`so this spelling grants exactly what "/${deniedCanonical}/**" grants — refused, scope a ` +
|
|
2242
|
+
`narrower path instead`,
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
if (canonicalPrefix !== literalPrefix) {
|
|
2247
|
+
return {
|
|
2248
|
+
verdict: 'error',
|
|
2249
|
+
code: 'non-canonical',
|
|
2250
|
+
message:
|
|
2251
|
+
`contains a non-canonical absolute path: ${entry} — hooks/enforce-scope.mjs Gate 5b ` +
|
|
2252
|
+
`matches the REALPATH-resolved write candidate, which never carries this spelling ` +
|
|
2253
|
+
`("${literalPrefix}" resolves to "${canonicalPrefix}"), so this grant can match NOTHING; ` +
|
|
2254
|
+
`write ${canonicalPrefix}${normalized.slice(literalPrefix.length)} instead`,
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// KNOWN DIVERGENCE, carried over from #1402 and still message-only: `*` is the
|
|
2259
|
+
// sole wildcard this check knows, while {@link isGlobScopeEntry} ALSO counts a
|
|
2260
|
+
// trailing `/` and {@link pathMatchesPattern} honours that directory-prefix
|
|
2261
|
+
// form. So `/private/tmp/x/` reads as "no wildcard" here and is described as an
|
|
2262
|
+
// exact-path match although it grants a whole subtree. Both branches only WARN,
|
|
2263
|
+
// so the cost is one wrong sentence on stderr and no verdict — which is why it
|
|
2264
|
+
// is reported rather than patched. Revisit if either branch ever hard-rejects.
|
|
2265
|
+
if (!entry.includes('*')) {
|
|
2266
|
+
return {
|
|
2267
|
+
verdict: 'warn',
|
|
2268
|
+
code: 'bare-absolute-file',
|
|
2269
|
+
message:
|
|
2270
|
+
`contains a bare absolute file grant with no wildcard: ${entry} — honoured by ` +
|
|
2271
|
+
`hooks/enforce-scope.mjs Gate 5b as an exact-path match (the narrowest possible grant); ` +
|
|
2272
|
+
`verify this grant is intentional`,
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
return {
|
|
2277
|
+
verdict: 'warn',
|
|
2278
|
+
code: 'absolute',
|
|
2279
|
+
message:
|
|
2280
|
+
`contains an absolute (out-of-repo) path: ${entry} — honoured by hooks/enforce-scope.mjs ` +
|
|
2281
|
+
`Gate 5b; verify this grant is intentional`,
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
|
|
1681
2285
|
/**
|
|
1682
2286
|
* Absolute epoch-ms at which this session started, or `null` when no clock is
|
|
1683
2287
|
* readable — the value {@link classifyEmptyScope} compares a manifest's mtime
|