@opengsd/gsd-core 1.8.0 → 1.9.1

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 (177) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +31 -1
  4. package/agents/gsd-code-fixer.md +107 -34
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +236 -107
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +882 -4
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +36 -9
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +61 -6
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/project-root.cjs +48 -0
  49. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  50. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  51. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  52. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  54. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  56. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  57. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  58. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  59. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  60. package/gsd-core/bin/lib/state-document.cjs +164 -20
  61. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  62. package/gsd-core/bin/lib/state.cjs +141 -21
  63. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  64. package/gsd-core/bin/lib/uat.cjs +9 -7
  65. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  66. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  67. package/gsd-core/bin/lib/validate.cjs +32 -0
  68. package/gsd-core/bin/lib/verification.cjs +51 -14
  69. package/gsd-core/bin/lib/verify.cjs +146 -22
  70. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  71. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  73. package/gsd-core/bin/shared/model-catalog.json +5 -0
  74. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  75. package/gsd-core/references/context-budget.md +40 -0
  76. package/gsd-core/references/gate-prompts.md +6 -3
  77. package/gsd-core/references/model-profile-resolution.md +64 -13
  78. package/gsd-core/references/offer-next.md +88 -0
  79. package/gsd-core/references/planning-config.md +2 -1
  80. package/gsd-core/references/reviewer-instances.md +28 -21
  81. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  82. package/gsd-core/references/ui-consideration-probe.md +2 -2
  83. package/gsd-core/references/worktree-branch-check.md +4 -4
  84. package/gsd-core/templates/summary-minimal.md +4 -0
  85. package/gsd-core/templates/summary-standard.md +4 -0
  86. package/gsd-core/templates/summary.md +7 -0
  87. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  88. package/gsd-core/workflows/audit-fix.md +4 -0
  89. package/gsd-core/workflows/audit-milestone.md +8 -0
  90. package/gsd-core/workflows/autonomous.md +19 -15
  91. package/gsd-core/workflows/check-todos.md +2 -2
  92. package/gsd-core/workflows/code-review-fix.md +14 -6
  93. package/gsd-core/workflows/code-review.md +93 -21
  94. package/gsd-core/workflows/debug.md +10 -2
  95. package/gsd-core/workflows/diagnose-issues.md +4 -0
  96. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  97. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  98. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  99. package/gsd-core/workflows/discuss-phase.md +2 -2
  100. package/gsd-core/workflows/docs-update.md +8 -0
  101. package/gsd-core/workflows/eval-review.md +1 -1
  102. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  103. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  104. package/gsd-core/workflows/execute-phase.md +85 -115
  105. package/gsd-core/workflows/execute-plan.md +5 -4
  106. package/gsd-core/workflows/explore.md +4 -0
  107. package/gsd-core/workflows/extract-learnings.md +21 -0
  108. package/gsd-core/workflows/help/modes/full.md +3 -3
  109. package/gsd-core/workflows/import.md +4 -1
  110. package/gsd-core/workflows/ingest-docs.md +4 -0
  111. package/gsd-core/workflows/map-codebase.md +13 -6
  112. package/gsd-core/workflows/new-milestone.md +10 -2
  113. package/gsd-core/workflows/new-project.md +11 -4
  114. package/gsd-core/workflows/next.md +5 -2
  115. package/gsd-core/workflows/plan-phase.md +42 -46
  116. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  117. package/gsd-core/workflows/progress.md +1 -1
  118. package/gsd-core/workflows/quick.md +14 -3
  119. package/gsd-core/workflows/review.md +146 -575
  120. package/gsd-core/workflows/scan.md +9 -1
  121. package/gsd-core/workflows/secure-phase.md +10 -2
  122. package/gsd-core/workflows/ship.md +41 -11
  123. package/gsd-core/workflows/smart-entry.md +1 -1
  124. package/gsd-core/workflows/ui-phase.md +8 -1
  125. package/gsd-core/workflows/ui-review.md +8 -1
  126. package/gsd-core/workflows/update.md +104 -5
  127. package/gsd-core/workflows/validate-phase.md +10 -2
  128. package/gsd-core/workflows/verify-work.md +8 -1
  129. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  130. package/hooks/dist/gsd-cursor-stop.js +6 -2
  131. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  132. package/hooks/dist/gsd-graphify-update.sh +9 -0
  133. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  134. package/hooks/dist/gsd-prompt-guard.js +101 -2
  135. package/hooks/dist/gsd-read-guard.js +100 -2
  136. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  137. package/hooks/dist/gsd-statusline.js +9 -6
  138. package/hooks/dist/gsd-workflow-guard.js +110 -6
  139. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  140. package/hooks/dist/lib/cursor-workspace.js +74 -0
  141. package/hooks/gsd-cursor-session-start.js +6 -2
  142. package/hooks/gsd-cursor-stop.js +6 -2
  143. package/hooks/gsd-cursor-subagent-start.js +6 -2
  144. package/hooks/gsd-graphify-update.sh +9 -0
  145. package/hooks/gsd-phase-boundary.sh +14 -2
  146. package/hooks/gsd-prompt-guard.js +101 -2
  147. package/hooks/gsd-read-guard.js +100 -2
  148. package/hooks/gsd-read-injection-scanner.js +109 -2
  149. package/hooks/gsd-statusline.js +9 -6
  150. package/hooks/gsd-workflow-guard.js +110 -6
  151. package/hooks/gsd-worktree-path-guard.js +132 -8
  152. package/hooks/lib/cursor-workspace.js +74 -0
  153. package/package.json +7 -7
  154. package/pi/gsd.cjs +26 -1
  155. package/scripts/check-coverage-gate.cjs +51 -0
  156. package/scripts/check-glossary-refs.cjs +24 -0
  157. package/scripts/ci-test-scope.cjs +67 -17
  158. package/scripts/gen-adr-index.cjs +6 -4
  159. package/scripts/gen-capability-matrix.cjs +26 -2
  160. package/scripts/gen-capability-registry.cjs +132 -34
  161. package/scripts/gen-emitted-baseline.cjs +145 -0
  162. package/scripts/gen-registry.cjs +39 -15
  163. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  164. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  165. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  166. package/scripts/lint-resolution-provenance.cjs +9 -0
  167. package/scripts/mutation-matrix.cjs +4 -0
  168. package/scripts/prompt-injection-scan.sh +6 -0
  169. package/scripts/registry-schema.cjs +372 -94
  170. package/scripts/release-notes/conventional-title.cjs +19 -1
  171. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  172. package/scripts/validate-registry.cjs +10 -6
  173. package/scripts/workflow-size.cjs +16 -8
  174. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  175. package/vscode/package.json +1 -1
  176. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  177. package/scripts/update-size-baseline.cjs +0 -68
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
- * Capability trust gate — ADR-1244 Phase 4 (Decision D5 + the compatibility half of D6).
3
+ * Capability trust gate — ADR-1244 Phase 4 (Decision D5 + the compatibility half of D6), extended
4
+ * by ADR-2782 Phase 3 (#2796) with a FOURTH executable-surface class: the reviewer lane.
4
5
  *
5
6
  * PURE module. It computes *what* a capability would do and *whether* policy allows it; it
6
7
  * never mutates the filesystem and never performs I/O beyond reading staged files to confirm
@@ -10,15 +11,28 @@
10
11
  *
11
12
  * LEAF MODULE — imports ONLY: node:fs, node:path, and ./semver-compare.cjs.
12
13
  *
14
+ * ADR-2782 D5 (#2796): a `reviewer` lane is piped the plan text, requirements, research findings
15
+ * and CONTEXT.md decisions, then its output is read back into REVIEWS.md — making it an executable
16
+ * surface exactly like a hook, command module, or MCP server, and it is disclosed and consent-bound
17
+ * the same way. `disclosureSignature` appends the lane element to its output ONLY when at least one
18
+ * lane is declared (D4.5) — a lane-free manifest's signature stays byte-identical to before this
19
+ * class existed, so no already-consented capability re-prompts on upgrade. The RESOLVED host (as
20
+ * opposed to the declared `hostConfigKey`) is disclosed to a human but deliberately EXCLUDED from
21
+ * the signature — the loader has no config resolver and must compute the same signature as the
22
+ * lifecycle (constraint 2, `.gsd/phase/chore-2796-reviewer-trust-disclosure/40-design.md`).
23
+ *
13
24
  * Exports:
14
25
  * RESERVED_NAMESPACES — id prefixes third parties may not claim
15
- * discloseExecutableSurfaces(...) — enumerate hooks / command modules / mcpServers
26
+ * discloseExecutableSurfaces(...) — enumerate hooks / command modules / mcpServers / reviewer lanes
27
+ * collectReviewerLaneSurfaces(...) — the reviewer-lane collector, independently testable
16
28
  * checkReservedNamespace(id) — is this id in a reserved namespace?
17
29
  * evaluateSourceAllowed(parsed,...) — strictKnownRegistries enforcement
18
30
  * checkEngines(manifest, host) — engines.gsd hard gate + compatVersions downgrade
19
31
  * evaluateInstallTrust(args) — compose: source + namespace + engines + disclosure
20
32
  * executableSetChanged(old, new) — did the executable surface set change between versions?
21
33
  * summarizeDisclosure(disclosure) — human-readable consent-prompt lines
34
+ * UNRESOLVED_HOST_MARKER — the non-blank marker for an unresolved openai-http host
35
+ * EGRESS_PAYLOAD_CLASSES — the named data classes every reviewer lane receives
22
36
  */
23
37
  var __importDefault = (this && this.__importDefault) || function (mod) {
24
38
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -36,6 +50,26 @@ const semverMod = require('./semver-compare.cjs');
36
50
  * one. Match is case-insensitive on the normalized id.
37
51
  */
38
52
  const RESERVED_NAMESPACES = ['gsd-', 'gsd-core-', 'anthropic-'];
53
+ /**
54
+ * ADR-2782 D5's gating requirement (#2796): every reviewer lane is piped the plan text,
55
+ * requirements, research findings and CONTEXT.md decisions. Named explicitly here so disclosure
56
+ * says exactly this — never the unhelpful "sends data to the tool" (design section B5).
57
+ */
58
+ const EGRESS_PAYLOAD_CLASSES = ['plan text', 'requirements', 'research findings', 'CONTEXT.md decisions'];
59
+ /**
60
+ * B3 (#2796 matrix): `resolvedHost` must never be a blank string — a blank reads as "no
61
+ * destination" rather than "not resolved". This marker is disclosed for an `openai-http` lane
62
+ * when no resolver was supplied to `collectReviewerLaneSurfaces`, or the supplied resolver could
63
+ * not resolve the declared `hostConfigKey`. Deliberately NOT part of `disclosureSignature`'s input
64
+ * (see the lane signature line) — only the human-facing surface carries it.
65
+ */
66
+ const UNRESOLVED_HOST_MARKER = '(unresolved — no host resolver was supplied at disclosure time)';
67
+ /**
68
+ * Loopback hostnames recognized LITERALLY, never by substring (an evil host must not spoof this,
69
+ * e.g. `notlocalhost.example`). D5: localhost is not "safe by default" — it is disclosed and
70
+ * FLAGGED, never omitted (matrix B4).
71
+ */
72
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
39
73
  // ---------------------------------------------------------------------------
40
74
  // Disclosure
41
75
  // ---------------------------------------------------------------------------
@@ -43,24 +77,161 @@ function asString(v) {
43
77
  return typeof v === 'string' ? v : '';
44
78
  }
45
79
  /**
46
- * Enumerate every executable surface a capability manifest declares.
80
+ * Run `fn`, returning `fallback` instead of throwing. Makes each per-class collector total: a
81
+ * hostile manifest (a Proxy with a throwing trap, a throwing getter, or a non-object/null root)
82
+ * degrades ONE surface class to empty rather than crashing disclosure for the other three classes
83
+ * behind it in the same manifest (ADR-2782 #2796 — disclosure runs before validation and must never
84
+ * throw; matrix C5/E2).
85
+ */
86
+ function safeCollect(fn, fallback) {
87
+ try {
88
+ return fn();
89
+ }
90
+ catch {
91
+ return fallback;
92
+ }
93
+ }
94
+ /**
95
+ * Recognize a loopback/local destination from a RESOLVED openai-http host value (matrix B4). Matches
96
+ * literally, never by substring — an evil host must not spoof `localhost` via e.g.
97
+ * `notlocalhost.example`. Falls back to a scheme-less leading-segment match so a bare config value
98
+ * like `localhost:1234` or `192.168.1.5:8080` (no `http://` prefix) is still recognized.
47
99
  *
48
- * Recognizes the three executable surface kinds a capability can ship:
49
- * - `hooks`: [{ event, script }] — scripts run as runtime hook commands
50
- * - `commands`:[{ family, module, router? }] — modules require()'d into the CLI process
51
- * - `mcpServers`: { <name>: {...} } | [{ name }] — servers spawned by the host runtime
100
+ * The fallback triggers on EITHER `new URL()` throwing (a value that is not parseable as an absolute
101
+ * URL at all, e.g. `192.168.1.5:8080` — WHATWG scheme names cannot start with a digit) OR it
102
+ * succeeding with an EMPTY hostname: `new URL('localhost:1234')` does NOT throw — it mis-parses the
103
+ * scheme-less `host:port` shape as an opaque URL whose "scheme" IS the hostname text
104
+ * (`protocol: "localhost:"`, `hostname: ""`), which would otherwise silently fail to recognize a
105
+ * bare local config value as local.
106
+ */
107
+ function isLocalHostValue(hostValue) {
108
+ let hostname = '';
109
+ try {
110
+ hostname = new URL(hostValue).hostname;
111
+ }
112
+ catch {
113
+ hostname = '';
114
+ }
115
+ if (!hostname) {
116
+ hostname = extractBareHost(hostValue);
117
+ }
118
+ // WHATWG returns an IPv6 hostname bracketed; a bare config value may not be.
119
+ const lower = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
120
+ if (LOOPBACK_HOSTNAMES.has(lower))
121
+ return true;
122
+ if (isLoopbackIpv6(lower))
123
+ return true;
124
+ return isLoopbackIpv4(lower);
125
+ }
126
+ /**
127
+ * Pull the host out of a value `new URL()` could not parse — a scheme-less
128
+ * `host:port`, or one carrying a path/query/fragment.
52
129
  *
53
- * `mcpServers` is not a first-party capability.json field today, but a third-party manifest may
54
- * declare it, so the trust gate discloses it whenever present (honest disclosure over the
55
- * narrower first-party schema). Pure: when `stagedDir` is provided, declared script/module
56
- * files are existence-checked and any missing ones reported, but nothing is mutated.
130
+ * IPv6 needs explicit handling: splitting on `:` mangles `[::1]:8080` to `[`,
131
+ * which then matches nothing and silently reports a loopback destination as
132
+ * remote. A bracketed literal is taken through its closing bracket; an unbracketed
133
+ * value with two or more colons is treated as a bare IPv6 address rather than
134
+ * `host:port`, since a host:port has exactly one.
57
135
  */
58
- function discloseExecutableSurfaces(manifest, stagedDir) {
136
+ function extractBareHost(hostValue) {
137
+ let s = String(hostValue).trim();
138
+ const schemeEnd = s.indexOf('://');
139
+ if (schemeEnd >= 0)
140
+ s = s.slice(schemeEnd + 3);
141
+ s = s.split(/[/?#]/)[0] || '';
142
+ if (s.startsWith('[')) {
143
+ const close = s.indexOf(']');
144
+ return close > 0 ? s.slice(1, close) : s;
145
+ }
146
+ const colons = (s.match(/:/g) || []).length;
147
+ if (colons >= 2)
148
+ return s;
149
+ return colons === 1 ? s.slice(0, s.indexOf(':')) : s;
150
+ }
151
+ /**
152
+ * Render one declared argv member for the human consent prompt.
153
+ *
154
+ * A string prints as itself. Anything else prints in a form that makes its
155
+ * presence and shape visible rather than vanishing: an argv member the host
156
+ * still receives, but which the user was never shown, is a surface consented to
157
+ * unseen. Never throws — a circular or BigInt member must not break the prompt.
158
+ */
159
+ function renderArgForHuman(arg) {
160
+ if (typeof arg === 'string')
161
+ return arg;
162
+ if (typeof arg === 'bigint')
163
+ return `<${String(arg)}n>`;
164
+ try {
165
+ const json = JSON.stringify(arg);
166
+ return json === undefined ? `<${typeof arg}>` : `<${json}>`;
167
+ }
168
+ catch {
169
+ return `<${typeof arg}>`;
170
+ }
171
+ }
172
+ /** `::1`, its expanded forms, and IPv4-mapped loopback (`::ffff:127.0.0.1`). */
173
+ function isLoopbackIpv6(host) {
174
+ if (!host.includes(':'))
175
+ return false;
176
+ if (host === '::1')
177
+ return true;
178
+ const mapped = /^::ffff:(.+)$/i.exec(host);
179
+ if (mapped)
180
+ return isLoopbackIpv4(mapped[1]);
181
+ const groups = host.split(':').filter((g) => g !== '');
182
+ if (groups.length === 0)
183
+ return false;
184
+ return groups.every((g, i) => (i === groups.length - 1 ? /^0*1$/.test(g) : /^0*$/.test(g)));
185
+ }
186
+ /**
187
+ * 127.0.0.0/8 under inet_aton semantics, which is what a browser, curl and the
188
+ * OS resolver all accept. `127.1`, `2130706433`, `0x7f000001` and `0177.0.0.1`
189
+ * are every bit as loopback as `127.0.0.1`; a disclosure that flags only the
190
+ * dotted-quad form understates a local destination for the other four.
191
+ */
192
+ function isLoopbackIpv4(host) {
193
+ const parts = host.split('.');
194
+ if (parts.length < 1 || parts.length > 4)
195
+ return false;
196
+ const nums = [];
197
+ for (const part of parts) {
198
+ let n;
199
+ if (/^0[xX][0-9a-fA-F]+$/.test(part))
200
+ n = parseInt(part, 16);
201
+ else if (/^0[0-7]+$/.test(part))
202
+ n = parseInt(part, 8);
203
+ else if (/^\d+$/.test(part))
204
+ n = parseInt(part, 10);
205
+ else
206
+ return false;
207
+ if (!Number.isFinite(n) || n < 0)
208
+ return false;
209
+ nums.push(n);
210
+ }
211
+ // inet_aton: the final part absorbs every remaining octet.
212
+ let addr;
213
+ if (nums.length === 1)
214
+ addr = nums[0];
215
+ else if (nums.length === 2)
216
+ addr = ((nums[0] & 0xff) * 0x1000000) + (nums[1] & 0xffffff);
217
+ else if (nums.length === 3)
218
+ addr = ((nums[0] & 0xff) * 0x1000000) + ((nums[1] & 0xff) * 0x10000) + (nums[2] & 0xffff);
219
+ else
220
+ addr = ((nums[0] & 0xff) * 0x1000000) + ((nums[1] & 0xff) * 0x10000) + ((nums[2] & 0xff) * 0x100) + (nums[3] & 0xff);
221
+ if (!Number.isFinite(addr) || addr < 0 || addr > 0xffffffff)
222
+ return false;
223
+ return Math.floor(addr / 0x1000000) === 127;
224
+ }
225
+ /**
226
+ * Collect the `hooks` executable-surface class: [{ event, script }] — scripts run as runtime hook
227
+ * commands. Extracted from the former monolithic `discloseExecutableSurfaces` (ADR-2782 #2796,
228
+ * cyclomatic 51 / cognitive 99 / 110 lines / `risk_level: critical`) — BEHAVIOR UNCHANGED, only
229
+ * isolated so it is independently testable and the orchestrator shrinks instead of growing a fourth
230
+ * class inline. `missingArtifacts` is a shared accumulator the orchestrator passes to every collector
231
+ * that can populate it.
232
+ */
233
+ function collectHookSurfaces(manifest, stagedDir, missingArtifacts) {
59
234
  const hooks = [];
60
- const commandModules = [];
61
- const mcpServers = [];
62
- const missingArtifacts = [];
63
- // hooks: [{ event, script }]
64
235
  if (Array.isArray(manifest.hooks)) {
65
236
  for (const h of manifest.hooks) {
66
237
  if (typeof h !== 'object' || h === null)
@@ -76,7 +247,14 @@ function discloseExecutableSurfaces(manifest, stagedDir) {
76
247
  }
77
248
  }
78
249
  }
79
- // commands: [{ family, module, router? }]
250
+ return hooks;
251
+ }
252
+ /**
253
+ * Collect the `commands` executable-surface class: [{ family, module, router? }] — modules
254
+ * require()'d into the GSD CLI process. Extracted, BEHAVIOR UNCHANGED — see `collectHookSurfaces`.
255
+ */
256
+ function collectCommandSurfaces(manifest, stagedDir, missingArtifacts) {
257
+ const commandModules = [];
80
258
  if (Array.isArray(manifest.commands)) {
81
259
  for (const c of manifest.commands) {
82
260
  if (typeof c !== 'object' || c === null)
@@ -94,9 +272,20 @@ function discloseExecutableSurfaces(manifest, stagedDir) {
94
272
  }
95
273
  }
96
274
  }
97
- // mcpServers: object map { name: { command, args } } OR array [{ name, command, args }]
98
- // (or array [{ name, config: { command, args } }]). Capture the COMMAND, not just the name —
99
- // the command is the executable that actually runs, and consent must disclose it (Codex R1 H1).
275
+ return commandModules;
276
+ }
277
+ /**
278
+ * Collect the `mcpServers` executable-surface class: object map { name: { command, args } } OR
279
+ * array [{ name, command, args }] (or array [{ name, config: { command, args } }]). Captures the
280
+ * COMMAND, not just the name — the command is the executable that actually runs, and consent must
281
+ * disclose it (Codex R1 H1). Extracted, BEHAVIOR UNCHANGED — see `collectHookSurfaces`. Unlike
282
+ * hooks/commands, an MCP server's command is never existence-checked against `stagedDir` (exactly
283
+ * like a reviewer lane's `binary` — see `collectReviewerLaneSurfaces` — it may be any PATH
284
+ * executable, not necessarily a bundle artifact), so this collector takes no `missingArtifacts`
285
+ * accumulator.
286
+ */
287
+ function collectMcpSurfaces(manifest) {
288
+ const mcpServers = [];
100
289
  if (manifest.mcpServers && typeof manifest.mcpServers === 'object') {
101
290
  const pushServer = (name, config) => {
102
291
  if (!name)
@@ -169,8 +358,152 @@ function discloseExecutableSurfaces(manifest, stagedDir) {
169
358
  }
170
359
  }
171
360
  }
172
- const hasExecutable = hooks.length > 0 || commandModules.length > 0 || mcpServers.length > 0;
173
- return { hooks, commandModules, mcpServers, hasExecutable, missingArtifacts };
361
+ return mcpServers;
362
+ }
363
+ /**
364
+ * Collect the reviewer-lane executable-surface class (ADR-2782 D5, #2796): 0 or 1 entries, since a
365
+ * capability manifest carries AT MOST ONE `reviewer` body (Phase 2's validator rejects an array
366
+ * shape outright — matrix C2b). The array return shape matches the other three collectors so
367
+ * `Disclosure`/`disclosureSignature` treat it uniformly (sort-then-fold), even though today it can
368
+ * never hold more than one entry.
369
+ *
370
+ * TOTAL and absent-safe (matrix C1–C5): no `reviewer` key, `reviewer: null`, a non-object body
371
+ * (array/boolean/number), a malformed `invoke`, non-array `flags`, or the whole manifest being a
372
+ * throwing Proxy/getter all degrade to "no lane" rather than throwing — disclosure runs BEFORE
373
+ * Phase 2's validation, on a manifest validation would reject outright.
374
+ *
375
+ * `resolveHost` is optional — supplied by the lifecycle (never the loader, which has no config
376
+ * access) to disclose the REAL destination of an `openai-http` lane to a human at install/upgrade
377
+ * time. Its return value is NEVER folded into `disclosureSignature` (design constraint 2: the
378
+ * signature must stay a pure function of the manifest, or the loader and lifecycle would compute
379
+ * different signatures for the same manifest and produce a permanent false re-consent loop).
380
+ */
381
+ function collectReviewerLaneSurfaces(manifest, resolveHost) {
382
+ return safeCollect(() => {
383
+ const r = manifest.reviewer;
384
+ // C1 (no reviewer key) / C2a (null) / C2b (non-object: array, boolean, number) all disclose no
385
+ // lane — never an error at this layer. Validation of a malformed body is Phase 2's job.
386
+ if (typeof r !== 'object' || r === null || Array.isArray(r))
387
+ return [];
388
+ const rec = r;
389
+ const slug = asString(rec['slug']);
390
+ const transport = asString(rec['transport']);
391
+ const handler = asString(rec['handler']);
392
+ // C3: `invoke` absent/malformed still discloses a lane, with empty binary/args/rawArgs rather
393
+ // than crashing — validating `invoke`'s shape is Phase 2's job, not disclosure's.
394
+ const invokeRaw = rec['invoke'];
395
+ const invoke = (typeof invokeRaw === 'object' && invokeRaw !== null && !Array.isArray(invokeRaw))
396
+ ? invokeRaw
397
+ : {};
398
+ const binary = asString(invoke['binary']);
399
+ // B1b: the RAW declared args (may contain non-strings the host still receives) is what the
400
+ // signature binds; `args` is the string-filtered RENDERED view for a human summary — the exact
401
+ // argv/rawArgs split MCP servers already use for the same reason (TRUST2-4, #1459).
402
+ const rawArgsDeclared = Array.isArray(invoke['args']) ? invoke['args'] : [];
403
+ const args = rawArgsDeclared.filter((a) => typeof a === 'string');
404
+ const hostConfigKey = asString(invoke['hostConfigKey']);
405
+ const promptChannel = asString(invoke['promptChannel']);
406
+ // An EMPTY (or wholly unrecognised) reviewer body declares no lane and must
407
+ // not be treated as one. Without this, `reviewer: {}` alone flips
408
+ // hasExecutable true and perturbs the disclosure signature — producing a
409
+ // re-consent prompt whose only content is "(no binary declared)". That is a
410
+ // prompt carrying no security information, which is exactly the
411
+ // click-through-training harm this design refuses for reviewsSection and
412
+ // timeoutFloorMs; refusing it there and permitting it here would be
413
+ // inconsistent.
414
+ //
415
+ // The test is deliberately BROAD — any one recognised field with a value is
416
+ // enough. Requiring specifically a binary, or specifically a slug, would let
417
+ // a lane declaring only the other slip through unconsented, which is the far
418
+ // worse failure.
419
+ const declaresSomething = Boolean(slug || transport || handler || binary || hostConfigKey || promptChannel
420
+ || rawArgsDeclared.length > 0);
421
+ if (!declaresSomething)
422
+ return [];
423
+ // B2/B3/B4: resolvedHost/isLocalDestination are only meaningful for an openai-http lane — a
424
+ // spawn lane has no destination concept, so both stay at their inapplicable defaults ('' /
425
+ // false), mirroring McpServerSurface's existing empty-when-inapplicable convention (e.g.
426
+ // `url: ''` for a stdio server). For openai-http, resolvedHost never ends up '' — it is either a
427
+ // real resolved value or the explicit UNRESOLVED_HOST_MARKER (never a blank read as "no
428
+ // destination").
429
+ // The shape test is deliberately WIDER than an exact transport match, and the
430
+ // human summary uses the same one. Disclosure runs BEFORE validation, so a
431
+ // mis-cased or unrecognised `transport` reaches here; keying only on the exact
432
+ // string would leave a lane that plainly declares a hostConfigKey with a BLANK
433
+ // destination, which reads as "no destination" — the precise thing B3 forbids.
434
+ const hasHttpShape = transport === 'openai-http' || (!binary && Boolean(hostConfigKey));
435
+ let resolvedHost = '';
436
+ let isLocalDestination = false;
437
+ if (hasHttpShape) {
438
+ resolvedHost = UNRESOLVED_HOST_MARKER;
439
+ if (typeof resolveHost === 'function') {
440
+ let resolved;
441
+ try {
442
+ resolved = resolveHost(hostConfigKey);
443
+ }
444
+ catch {
445
+ resolved = undefined;
446
+ }
447
+ if (typeof resolved === 'string' && resolved)
448
+ resolvedHost = resolved;
449
+ }
450
+ if (resolvedHost !== UNRESOLVED_HOST_MARKER) {
451
+ isLocalDestination = isLocalHostValue(resolvedHost);
452
+ }
453
+ }
454
+ const surface = {
455
+ slug,
456
+ transport,
457
+ binary,
458
+ args,
459
+ rawArgs: rawArgsDeclared,
460
+ hostConfigKey,
461
+ resolvedHost,
462
+ isLocalDestination,
463
+ promptChannel,
464
+ handler,
465
+ // B5: every lane receives the same named egress payload classes — a fresh copy per surface so
466
+ // no caller can mutate the shared constant through a returned surface.
467
+ egressPayloadClasses: [...EGRESS_PAYLOAD_CLASSES],
468
+ };
469
+ return [surface];
470
+ }, []);
471
+ }
472
+ /**
473
+ * Enumerate every executable surface a capability manifest declares.
474
+ *
475
+ * Recognizes the FOUR executable surface kinds a capability can ship:
476
+ * - `hooks`: [{ event, script }] — scripts run as runtime hook commands
477
+ * - `commands`: [{ family, module, router? }] — modules require()'d into the CLI process
478
+ * - `mcpServers`: { <name>: {...} } | [{ name }] — servers spawned by the host runtime
479
+ * - `reviewer`: { slug, transport, invoke, ... } — an external reviewer lane (ADR-2782 D5, #2796)
480
+ *
481
+ * `mcpServers` is not a first-party capability.json field today, but a third-party manifest may
482
+ * declare it, so the trust gate discloses it whenever present (honest disclosure over the
483
+ * narrower first-party schema). Pure: when `stagedDir` is provided, declared hook/command-module
484
+ * files are existence-checked and any missing ones reported, but nothing is mutated. A reviewer
485
+ * lane's `binary` is NEVER existence-checked against `stagedDir` (matrix C6) — like an MCP server's
486
+ * command, it is a PATH lookup on the user's machine, never a bundle artifact; existence-checking it
487
+ * would block every lane install.
488
+ *
489
+ * TOTAL: never throws, for any manifest shape — including a non-object manifest, a Proxy with
490
+ * throwing traps, or a property with a throwing getter (matrix C5, E2). Disclosure runs BEFORE
491
+ * Phase 2's validation, on a manifest validation would reject outright, so it must tolerate what
492
+ * validation does not. Each surface class is collected independently (`safeCollect`) so a hostile
493
+ * value in ONE class degrades only that class to empty rather than losing the other three.
494
+ *
495
+ * `resolveHost` (optional, #2796) is forwarded to `collectReviewerLaneSurfaces` so a caller with
496
+ * config access (the lifecycle, never the loader — see `signatureForManifest`) can disclose the REAL
497
+ * destination of an `openai-http` lane. It never affects the returned signature.
498
+ */
499
+ function discloseExecutableSurfaces(manifest, stagedDir, resolveHost) {
500
+ const missingArtifacts = [];
501
+ const hooks = safeCollect(() => collectHookSurfaces(manifest, stagedDir, missingArtifacts), []);
502
+ const commandModules = safeCollect(() => collectCommandSurfaces(manifest, stagedDir, missingArtifacts), []);
503
+ const mcpServers = safeCollect(() => collectMcpSurfaces(manifest), []);
504
+ const reviewerLanes = safeCollect(() => collectReviewerLaneSurfaces(manifest, resolveHost), []);
505
+ const hasExecutable = hooks.length > 0 || commandModules.length > 0 || mcpServers.length > 0 || reviewerLanes.length > 0;
506
+ return { hooks, commandModules, mcpServers, reviewerLanes, hasExecutable, missingArtifacts };
174
507
  }
175
508
  /**
176
509
  * Existence-check a manifest-declared artifact path under stagedDir, refusing to follow it
@@ -358,7 +691,7 @@ function checkEngines(manifest, hostVersion) {
358
691
  * is defense-in-depth and lets callers surface a compatVersions downgrade hint.
359
692
  */
360
693
  function evaluateInstallTrust(args) {
361
- const { parsed, manifest, stagedDir, strictKnownRegistries, hostVersion } = args;
694
+ const { parsed, manifest, stagedDir, strictKnownRegistries, hostVersion, resolveHost } = args;
362
695
  const blockReasons = [];
363
696
  const src = evaluateSourceAllowed(parsed, strictKnownRegistries);
364
697
  if (!src.allowed && src.reason)
@@ -375,7 +708,10 @@ function evaluateInstallTrust(args) {
375
708
  : '';
376
709
  blockReasons.push(`capability requires engines.gsd "${engines.range}" but host is ${hostVersion}${hint}`);
377
710
  }
378
- const disclosure = discloseExecutableSurfaces(manifest, stagedDir);
711
+ // #2796: resolveHost is optional and, when supplied, discloses the REAL destination of an
712
+ // openai-http reviewer lane to the human at install/upgrade time — it never affects the
713
+ // consent-binding signature (disclosureSignature never reads resolvedHost; design constraint 2).
714
+ const disclosure = discloseExecutableSurfaces(manifest, stagedDir, resolveHost);
379
715
  // A manifest that declares a hook script or command module NOT present in the staged bundle
380
716
  // (missing, or escaping the bundle via an absolute/`..` path) is rejected: such an artifact
381
717
  // would run from outside the integrity-pinned, reversible install root. Only enforced when a
@@ -395,15 +731,61 @@ function evaluateInstallTrust(args) {
395
731
  * reordering. Used to fold an MCP server's `env` map into the disclosure signature: ADDING or
396
732
  * CHANGING any env entry changes the signature (forces re-consent), but merely REORDERING the keys
397
733
  * does NOT (no false re-prompt). TRUST-2 (#1459).
734
+ *
735
+ * TOTAL (#2796, matrix C5c/E2): a value declared inside an unvalidated manifest — e.g. a reviewer
736
+ * lane's `invoke.args` — may contain a BigInt (which `JSON.stringify` throws on) or a circular
737
+ * reference (which unguarded recursion stack-overflows on). Both are handled without throwing:
738
+ * a BigInt renders as its decimal string; a cycle (an object that is its OWN ancestor in the current
739
+ * recursion path — tracked via `seen`, added before recursing into children and removed once fully
740
+ * processed) renders as the literal string `"[Circular]"`. Neither case is reachable for the golden
741
+ * hooks/mods/mcp fixtures this phase's byte-identity tests pin down, so their output is unaffected.
742
+ *
743
+ * KNOWN LIMIT — signature collision on non-JSON numerics (#2796 isolated review, finding E).
744
+ * `NaN`, `Infinity`, `-Infinity` and `undefined` all render as `null` here, inheriting
745
+ * `JSON.stringify`'s own coercion. Two materially different manifests could therefore share a
746
+ * consent signature. This is NOT reachable through any production path: every manifest arrives via
747
+ * `readManifestBounded`'s strict `JSON.parse`, and the JSON grammar has no `NaN`/`Infinity`/
748
+ * `undefined` literal — such input throws before disclosure runs. `0` vs `-0` IS expressible in
749
+ * valid JSON and does collide, but is inert: `String(0) === String(-0)`, so a spawned process
750
+ * receives identical argv either way.
751
+ *
752
+ * Recorded here rather than only in the PR that found it: reachability rests entirely on the ingest
753
+ * path staying `JSON.parse`-only. Anyone who adds a loader that builds a manifest by other means
754
+ * (a JS config file, a deserializer, a test double promoted to production) re-opens this, and needs
755
+ * to see it at the point they would break it.
398
756
  */
399
- function stableJson(value) {
400
- if (value === null || typeof value !== 'object')
401
- return JSON.stringify(value) ?? 'null';
402
- if (Array.isArray(value))
403
- return `[${value.map(stableJson).join(',')}]`;
404
- const obj = value;
405
- const keys = Object.keys(obj).sort();
406
- return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
757
+ function stableJson(value, seen) {
758
+ if (typeof value === 'bigint')
759
+ return JSON.stringify(`${value.toString()}n`);
760
+ if (value === null || typeof value !== 'object') {
761
+ try {
762
+ return JSON.stringify(value) ?? 'null';
763
+ }
764
+ catch {
765
+ // A non-object value whose serialization still throws (defensive; JSON.stringify does not
766
+ // throw for any other typeof today, but this keeps the contract TOTAL against future engines).
767
+ return 'null';
768
+ }
769
+ }
770
+ const seenSet = seen ?? new Set();
771
+ if (seenSet.has(value))
772
+ return '"[Circular]"';
773
+ try {
774
+ seenSet.add(value);
775
+ if (Array.isArray(value)) {
776
+ return `[${value.map((v) => stableJson(v, seenSet)).join(',')}]`;
777
+ }
778
+ const obj = value;
779
+ const keys = Object.keys(obj).sort();
780
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k], seenSet)}`).join(',')}}`;
781
+ }
782
+ catch {
783
+ // A Proxy with a throwing trap, or a getter that throws on read — never propagate (matrix C5).
784
+ return '"[unserializable]"';
785
+ }
786
+ finally {
787
+ seenSet.delete(value);
788
+ }
407
789
  }
408
790
  function disclosureSignature(d) {
409
791
  // TRUST2-1 (#1459): build EVERY surface line via stableJson of an ARRAY of its components, so each
@@ -441,7 +823,23 @@ function disclosureSignature(d) {
441
823
  s.rawConfig || {},
442
824
  ]))
443
825
  .sort();
444
- return JSON.stringify([hooks, mods, mcp]);
826
+ // ADR-2782 D5 (#2796): fold in slug/transport/binary/rawArgs/hostConfigKey/promptChannel/handler —
827
+ // every field that changes WHAT runs, WHERE it sends data, or WHAT CODE post-processes its output
828
+ // (matrix A3–A9). Deliberately ABSENT from this line: `reviewsSection` and `timeoutFloorMs` (matrix
829
+ // A10/A13 — cosmetic fields; folding them in would force a re-consent prompt that carries no
830
+ // security information, training users to click through) and the RESOLVED host (design constraint
831
+ // 2 — the loader has no config resolver and must compute the SAME signature as the lifecycle, or a
832
+ // resolver-bearing caller and a resolver-less caller would permanently disagree on one manifest's
833
+ // signature).
834
+ const lanes = d.reviewerLanes
835
+ .map((l) => stableJson(['lane', l.slug, l.transport, l.binary, l.rawArgs || [], l.hostConfigKey, l.promptChannel, l.handler]))
836
+ .sort();
837
+ // D4.5 (the highest-consequence line in this phase): the lane element is appended ONLY when at
838
+ // least one lane is declared. A lane-free manifest's signature stays BYTE-IDENTICAL to before this
839
+ // class existed (matrix A1a/A1b/A1c) — appending unconditionally would change every already-
840
+ // installed capability's signature and re-prompt every user for every capability on next upgrade,
841
+ // whether or not they use any reviewer lane at all.
842
+ return lanes.length > 0 ? JSON.stringify([hooks, mods, mcp, lanes]) : JSON.stringify([hooks, mods, mcp]);
445
843
  }
446
844
  /**
447
845
  * Did the executable surface set change between two versions? Auto-update must re-prompt for
@@ -527,6 +925,36 @@ function summarizeDisclosure(disclosure) {
527
925
  lines.push(` cwd: ${s.cwd}`);
528
926
  }
529
927
  }
928
+ if (disclosure.reviewerLanes.length > 0) {
929
+ lines.push(` reviewer lane (${disclosure.reviewerLanes.length}): an external reviewer receives plan/review data on every run`);
930
+ for (const l of disclosure.reviewerLanes) {
931
+ // B1/B2/B3/B4: disclose binary+args for a spawn lane, or hostConfigKey+resolved destination
932
+ // (flagged local when applicable, never omitted as "safe") for an openai-http lane — never
933
+ // curl/the transport name alone, which would be true and useless (design B2).
934
+ // Branch on the DECLARED SHAPE, not on an exact transport string. A lane
935
+ // whose transport is mis-cased or unrecognised still has a hostConfigKey,
936
+ // and falling through to the spawn branch would print "(no binary
937
+ // declared)" for a lane that in fact egresses to a live remote host —
938
+ // understating the disclosure precisely when it matters. Disclosure runs
939
+ // BEFORE validation, so a non-canonical transport does reach this code.
940
+ if (l.transport === 'openai-http' || (!l.binary && l.hostConfigKey)) {
941
+ const localTag = l.isLocalDestination ? ' [local]' : '';
942
+ lines.push(` - ${l.slug || '(slug?)'} -> [openai-http] ${l.hostConfigKey || '(hostConfigKey?)'} => ${l.resolvedHost}${localTag}`);
943
+ }
944
+ else {
945
+ // Render the RAW declared args, not the string-filtered view. The raw
946
+ // array is what the host receives and what the consent signature binds,
947
+ // so a non-string member that is invisible here is a surface the user
948
+ // consented to without being shown — the opposite of the disclosure's
949
+ // whole purpose.
950
+ const cmd = [l.binary, ...l.rawArgs.map(renderArgForHuman)].filter(Boolean).join(' ');
951
+ lines.push(` - ${l.slug || '(slug?)'} -> ${cmd || '(no binary declared)'}`);
952
+ }
953
+ if (l.handler)
954
+ lines.push(` handler: ${l.handler}`);
955
+ lines.push(` sends: ${l.egressPayloadClasses.join(', ')}`);
956
+ }
957
+ }
530
958
  if (disclosure.missingArtifacts.length > 0) {
531
959
  lines.push(' WARNING — declared artifacts not found in the staged bundle:');
532
960
  for (const a of disclosure.missingArtifacts) {
@@ -538,6 +966,9 @@ function summarizeDisclosure(disclosure) {
538
966
  module.exports = {
539
967
  RESERVED_NAMESPACES,
540
968
  discloseExecutableSurfaces,
969
+ // #2796: the reviewer-lane collector, exported for independent testability (ADR-2782's own
970
+ // argument for extracting per-class collectors rather than growing the switch inline).
971
+ collectReviewerLaneSurfaces,
541
972
  checkReservedNamespace,
542
973
  evaluateSourceAllowed,
543
974
  checkEngines,
@@ -547,4 +978,8 @@ module.exports = {
547
978
  // #1459: the consent-binding signature (single source of truth for loader + lifecycle consent).
548
979
  disclosureSignature,
549
980
  signatureForManifest,
981
+ // #2796: the non-blank unresolved-host marker and the named egress payload classes, exported so
982
+ // tests can assert exact equality rather than a loose substring match.
983
+ UNRESOLVED_HOST_MARKER,
984
+ EGRESS_PAYLOAD_CLASSES,
550
985
  };