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.
Files changed (295) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +5 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +5 -1
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +5 -1
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +5 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +5 -1
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +5 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +5 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +5 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +5 -1
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +5 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  83. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  84. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  85. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  86. package/.codex-plugin/skills/session/SKILL.md +1 -1
  87. package/.cursor/commands/eval.md +1 -1
  88. package/.cursor/commands/session.md +1 -1
  89. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  90. package/.cursor/rules/050-plan.mdc +1 -1
  91. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  92. package/.cursor/skills/eval/SKILL.md +1 -1
  93. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  94. package/.cursor-plugin/plugin.json +1 -1
  95. package/.orchestrator/policy/blocked-commands.json +12 -3
  96. package/AGENTS.md +3 -2
  97. package/CHANGELOG.md +136 -0
  98. package/README.md +9 -9
  99. package/SECURITY.md +12 -0
  100. package/agents/dialectic-deriver.md +13 -10
  101. package/agents/eval-judge.md +67 -45
  102. package/agents/skill-applied-judge.md +34 -19
  103. package/commands/session.md +7 -3
  104. package/docs/baseline.md +12 -6
  105. package/docs/codex-setup.md +14 -2
  106. package/docs/components.md +7 -5
  107. package/docs/events-schema.md +56 -9
  108. package/docs/rule-authoring.md +58 -6
  109. package/docs/session-config-reference.md +100 -7
  110. package/docs/session-config-template.md +31 -2
  111. package/docs/telemetry.md +2 -0
  112. package/hooks/_lib/hook-import-set.json +85 -8
  113. package/hooks/_lib/subagent-transcript.mjs +582 -31
  114. package/hooks/config-protection.mjs +11 -3
  115. package/hooks/cwd-change-restore.mjs +11 -3
  116. package/hooks/enforce-commands.mjs +70 -23
  117. package/hooks/enforce-scope.mjs +143 -33
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/loop-guard.mjs +11 -3
  121. package/hooks/on-session-end.mjs +58 -23
  122. package/hooks/on-session-start.mjs +48 -11
  123. package/hooks/on-stop.mjs +168 -22
  124. package/hooks/operator-steer.mjs +11 -3
  125. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  126. package/hooks/post-bash-write-verify.mjs +3 -2
  127. package/hooks/post-edit-import-probe.mjs +17 -9
  128. package/hooks/post-edit-validate.mjs +13 -5
  129. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  130. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  131. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  132. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  133. package/hooks/pre-auq-clarity.mjs +15 -2
  134. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  135. package/hooks/pre-bash-issue-budget.mjs +16 -11
  136. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  138. package/hooks/pre-bash-staging-fence.mjs +335 -31
  139. package/hooks/pre-bash-templates-first.mjs +19 -14
  140. package/hooks/pre-task-scope-disjoint.mjs +233 -2
  141. package/hooks/subagent-telemetry.mjs +15 -19
  142. package/hooks/wave-scope-commit-guard.mjs +197 -100
  143. package/monitors/monitors.json +1 -1
  144. package/output-styles/wave-summary.md +1 -1
  145. package/package.json +1 -1
  146. package/pi/prompts/eval.md +1 -1
  147. package/pi/prompts/session.md +1 -1
  148. package/rules/README.md +1 -1
  149. package/rules/opt-in-domain/prompt-caching.md +1 -1
  150. package/rules/opt-in-stack/backend-data.md +1 -1
  151. package/rules/opt-in-stack/backend.md +3 -3
  152. package/rules/opt-in-stack/frontend.md +1 -1
  153. package/rules/opt-in-stack/security-web.md +3 -3
  154. package/rules/opt-in-stack/swift.md +1 -1
  155. package/scripts/autopilot.mjs +23 -2
  156. package/scripts/backfill-abandoned-sessions.mjs +117 -15
  157. package/scripts/check-sessions-integrity.mjs +300 -0
  158. package/scripts/dialectic-deriver.mjs +50 -13
  159. package/scripts/emit-session.mjs +75 -29
  160. package/scripts/eval-session.mjs +65 -3
  161. package/scripts/generate-agents-skills.mjs +102 -29
  162. package/scripts/generate-cursor-adapter.mjs +61 -16
  163. package/scripts/lib/agent-status.mjs +2 -31
  164. package/scripts/lib/auq/clarity.mjs +10 -2
  165. package/scripts/lib/auq/parse.mjs +12 -31
  166. package/scripts/lib/auq/schema.mjs +56 -41
  167. package/scripts/lib/auto-dialectic.mjs +304 -15
  168. package/scripts/lib/autopilot/flags.mjs +12 -1
  169. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  170. package/scripts/lib/autopilot/loop.mjs +14 -1
  171. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  172. package/scripts/lib/ci-status-banner.mjs +376 -16
  173. package/scripts/lib/command-blocker.mjs +275 -28
  174. package/scripts/lib/config/dialectic.mjs +12 -3
  175. package/scripts/lib/config/gate.mjs +74 -0
  176. package/scripts/lib/config/reaper.mjs +162 -0
  177. package/scripts/lib/config.mjs +14 -0
  178. package/scripts/lib/convergence-monitor.mjs +74 -11
  179. package/scripts/lib/ecosystem-health.mjs +11 -0
  180. package/scripts/lib/eval/engine.mjs +421 -53
  181. package/scripts/lib/eval/judge.mjs +463 -40
  182. package/scripts/lib/eval/schema.mjs +10 -1
  183. package/scripts/lib/events-rotation.mjs +221 -25
  184. package/scripts/lib/events-schema.mjs +114 -0
  185. package/scripts/lib/events.mjs +524 -5
  186. package/scripts/lib/frontmatter-guard.mjs +21 -10
  187. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  188. package/scripts/lib/gates/gate-full.mjs +28 -3
  189. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  190. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  191. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  192. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  193. package/scripts/lib/instruction-budget-guard.mjs +146 -4
  194. package/scripts/lib/io.mjs +42 -8
  195. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  196. package/scripts/lib/js-mask.mjs +197 -0
  197. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  198. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  199. package/scripts/lib/orphan-reaper.mjs +1588 -0
  200. package/scripts/lib/peer-cards/merger.mjs +48 -10
  201. package/scripts/lib/peer-cards/reader.mjs +78 -2
  202. package/scripts/lib/process-group.mjs +899 -0
  203. package/scripts/lib/quality-gate.mjs +107 -28
  204. package/scripts/lib/reconcile/backlog.mjs +368 -0
  205. package/scripts/lib/reconcile/engine.mjs +55 -188
  206. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  207. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  208. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  209. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  210. package/scripts/lib/rule-loader.mjs +41 -12
  211. package/scripts/lib/scope-echo.mjs +39 -2
  212. package/scripts/lib/scope-gate.mjs +605 -1
  213. package/scripts/lib/session-close-backfill.mjs +33 -6
  214. package/scripts/lib/session-id.mjs +9 -20
  215. package/scripts/lib/session-invocation.mjs +20 -0
  216. package/scripts/lib/session-schema/constants.mjs +30 -2
  217. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  218. package/scripts/lib/session-schema.mjs +8 -3
  219. package/scripts/lib/session-start-probes.mjs +95 -10
  220. package/scripts/lib/sessions-canonical.mjs +23 -0
  221. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  222. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  223. package/scripts/lib/skill-evidence-window.mjs +891 -0
  224. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  225. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  226. package/scripts/lib/skill-judge.mjs +45 -3
  227. package/scripts/lib/tail-window.mjs +56 -0
  228. package/scripts/lib/telemetry/schema.mjs +30 -0
  229. package/scripts/lib/telemetry/sync.mjs +61 -6
  230. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  231. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  232. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  233. package/scripts/lib/user-invocable-skills.mjs +23 -3
  234. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  235. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  236. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  237. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  238. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  239. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  240. package/scripts/lib/validate/check-rules.mjs +7 -5
  241. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  242. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  243. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  244. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  245. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  246. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  247. package/scripts/lib/validate/confidential-names.mjs +10 -0
  248. package/scripts/lib/validate-vendored-rules.mjs +4 -3
  249. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  250. package/scripts/lib/vault-mirror/process.mjs +10 -3
  251. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  252. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  253. package/scripts/lib/vault-yaml.mjs +118 -0
  254. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  255. package/scripts/release-session-lock.mjs +305 -0
  256. package/scripts/release.mjs +30 -5
  257. package/scripts/resolve-session-invocation.mjs +59 -0
  258. package/scripts/run-quality-gate.mjs +156 -17
  259. package/scripts/sweep-expired-rules.mjs +14 -3
  260. package/scripts/validate-plugin.mjs +12 -0
  261. package/scripts/validate-wave-scope.mjs +32 -105
  262. package/scripts/vault-mirror.mjs +9 -1
  263. package/skills/_shared/platform-tools.md +23 -11
  264. package/skills/autopilot/SKILL.md +22 -7
  265. package/skills/claude-md-drift-check/SKILL.md +1 -1
  266. package/skills/convergence-monitoring/README.md +8 -1
  267. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  268. package/skills/convergence-monitoring/SKILL.md +15 -6
  269. package/skills/eval/SKILL.md +39 -24
  270. package/skills/eval/rubric-v1.md +1 -0
  271. package/skills/eval/rubric-v2.md +457 -0
  272. package/skills/evolve/SKILL.md +1 -1
  273. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  274. package/skills/gitlab-ops/SKILL.md +3 -2
  275. package/skills/npm-publish/SKILL.md +1 -1
  276. package/skills/reconcile/SKILL.md +11 -0
  277. package/skills/session-end/SKILL.md +13 -16
  278. package/skills/session-end/discovery-scan.md +1 -1
  279. package/skills/session-end/phase-3-6-tail.md +55 -9
  280. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  281. package/skills/session-end/session-metrics-write.md +10 -0
  282. package/skills/session-plan/SKILL.md +17 -5
  283. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  284. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
  285. package/skills/ux-grill/SKILL.md +1 -1
  286. package/skills/wave-executor/SKILL.md +8 -4
  287. package/skills/wave-executor/circuit-breaker.md +2 -0
  288. package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
  289. package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
  290. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
  291. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
  292. package/.cursor/commands/convergence-monitoring.md +0 -13
  293. package/.cursor/commands/npm-publish.md +0 -13
  294. package/pi/prompts/convergence-monitoring.md +0 -11
  295. 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