@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +31 -1
- package/agents/gsd-code-fixer.md +107 -34
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +36 -0
- package/agents/gsd-executor.md +20 -7
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +15 -23
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/bin/install.js +236 -107
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/gsd-core/bin/gsd-tools.cjs +882 -4
- package/gsd-core/bin/lib/api-coverage.cjs +22 -8
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/check-command-router.cjs +12 -2
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
- package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
- package/gsd-core/bin/lib/commands.cjs +246 -18
- package/gsd-core/bin/lib/config-loader.cjs +200 -28
- package/gsd-core/bin/lib/config.cjs +90 -5
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +44 -19
- package/gsd-core/bin/lib/install-engine.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +36 -9
- package/gsd-core/bin/lib/model-catalog.cjs +51 -1
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +278 -5
- package/gsd-core/bin/lib/phase.cjs +61 -6
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +1 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
- package/gsd-core/bin/lib/roadmap.cjs +10 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +1 -1
- package/gsd-core/bin/lib/state-document.cjs +164 -20
- package/gsd-core/bin/lib/state-transition.cjs +28 -10
- package/gsd-core/bin/lib/state.cjs +141 -21
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +9 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +32 -0
- package/gsd-core/bin/lib/verification.cjs +51 -14
- package/gsd-core/bin/lib/verify.cjs +146 -22
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -21
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/ai-integration-phase.md +4 -4
- package/gsd-core/workflows/audit-fix.md +4 -0
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +93 -21
- package/gsd-core/workflows/debug.md +10 -2
- package/gsd-core/workflows/diagnose-issues.md +4 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/docs-update.md +8 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase.md +85 -115
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/help/modes/full.md +3 -3
- package/gsd-core/workflows/import.md +4 -1
- package/gsd-core/workflows/ingest-docs.md +4 -0
- package/gsd-core/workflows/map-codebase.md +13 -6
- package/gsd-core/workflows/new-milestone.md +10 -2
- package/gsd-core/workflows/new-project.md +11 -4
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/plan-phase.md +42 -46
- package/gsd-core/workflows/plan-review-convergence.md +18 -14
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +14 -3
- package/gsd-core/workflows/review.md +146 -575
- package/gsd-core/workflows/scan.md +9 -1
- package/gsd-core/workflows/secure-phase.md +10 -2
- package/gsd-core/workflows/ship.md +41 -11
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/ui-phase.md +8 -1
- package/gsd-core/workflows/ui-review.md +8 -1
- package/gsd-core/workflows/update.md +104 -5
- package/gsd-core/workflows/validate-phase.md +10 -2
- package/gsd-core/workflows/verify-work.md +8 -1
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +9 -6
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +9 -6
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +7 -7
- package/pi/gsd.cjs +26 -1
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +24 -0
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +6 -4
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +372 -94
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/validate-registry.cjs +10 -6
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- 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
|
-
*
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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 (
|
|
401
|
-
return JSON.stringify(value)
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
};
|