* fix(#4594): give dispatch identity one owner for the emitted format and its parser The isolation guards decided whether a run-scoped sentinel applied to a dispatch by regex-scraping model-authored prose. The scrape returned values in a different namespace from the ones the sentinel records, so the comparison could never succeed: sentinel { phase: "03", plan: "03-02-hardening" } <- $PHASE_NUMBER / $plan_id prose "Execute plan 02 of phase 03-auth." scraped { phase: "03-auth.", plan: "02" } <- greedy (\S+), both wrong #4594 reports only the phase half. Measured against a real phase-plan-index run, plans[].id is phase-prefixed, plan-numbered AND slugged, while the prose carries a bare in-phase plan number — so the plan field mismatches too, and the Claude path is dead rather than latent. A fresh sentinel was therefore discarded on every executor dispatch and every legitimate ISOLATION=none degrade was denied, leaving the work unrun. hooks/lib/dispatch-identity.js is now the single owner of both halves. The two prompt-body producers emit a canonical marker carrying the same shell values the sentinel records, so producer and consumer agree by construction. The prose frame stays as a fallback, bounded by the phase-token grammar ADR-2121 owns and deliberately reporting no plan — an absent identifier means "cannot compare" and is safe; a wrong one is a false mismatch and is not. The prose sentence itself is byte-identical: the executor agent reads it too, so the marker is purely additive (Hyrum's Law). An inapplicable sentinel is now named in the guards' deny reason instead of being dropped silently — the silence is why this survived three producers and two consumers unnoticed. Interpolated values come from a sentinel file and from prompt text, so both are length-bounded and stripped of control characters. ADR-4630 locks the seam and maps the epic's three phases. Refs #4630 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4594): resolve eight review findings across the dispatch-identity seam Three orthogonal review engines ran on 43418af144 — the code-review skill's Standards and Spec axes, and an isolated adversarial security pass — plus a self-review of the committed diff. Every finding is fixed here; none deferred. F1 (major, reproduced). A keyless or unknown-key-only marker — the literal "[gsd:dispatch]" or "[gsd:dispatch run=..]" — matched the marker grammar and returned source:'marker' with both fields null, suppressing the prose fallback entirely. Any prompt text containing that literal silently disabled identity narrowing, so a fresh sentinel applied to a dispatch it was never scoped to, defeating #3045 SECURITY F2. Prompt text is attacker-influenceable. A marker that yields neither recognized key is no longer a marker: the scan continues to later markers, then later texts, then prose. Forward-compatible tolerance of unknown keys is unchanged. F2/F3 (major). The first cut duplicated sanitizeForReason, describeSentinelDiscard and REASON_INTERPOLATION_MAX_LEN byte-for-byte across both guards — the exact defect class this epic exists to delete, and with no cold-load justification, since both hooks already require hooks/lib/. They now live in hooks/lib/isolation-deny-reason.js, and buildSentinelDiscard lives in isolation-sentinel.js beside the comparison it mirrors, returning the nested {sentinel:{phase,plan}, dispatch:{phase,plan}} shape instead of a bespoke four-field bag that renamed the pairs already flowing through the seam. F4 (hard violation). The visibility test asserted on the deny reason's prose. CONTRIBUTING.md prohibits raw text matching on hook output, which is why every deny carries a stable reason_code. The discard is now a structured sentinel_discarded field on each hook's stdout JSON, and the test asserts that; the sentence stays for the operator but is no longer the contract. F5 (hard violation). The 64-character truncation limit had no boundary coverage. 63/64/65 are now exercised against the single consolidated helper. F6 (minor). sanitizeForReason stripped C0/C1 controls but not U+2028/U+2029 or the bidi overrides, so a crafted value could still reflow or reverse the message. Both classes are stripped, with a test each. F7 (major). The producer/template parity test was vacuous — it rendered a marker and re-parsed its own output, and would have passed with both templates deleted. It now reads the two workflow templates, extracts each marker line, substitutes the measured values and asserts the owner's parser returns them. Proven red by deleting one template's marker line before being proven green. F8 (doc). ADR-4630 and the design notes claimed the marker is guaranteed on the orchestrator-worktree path because that prompt is built in shell. It is not: executor-isolation-dispatch.md:131 says plainly that those are template placeholders, not shell variables, so {plan_id} is model-substituted there too. A false guarantee in a design lock is worse than a stated limit. Both documents now say the marker is model-substituted on both paths and that the prose fallback is the real floor everywhere. The "3 workflow templates" count was also wrong — 3 prose sites across 2 files, 2 of which carry the marker. Refs #4630 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4594): refresh the compact-content baseline and acknowledge execute-phase.md growth Refs #4630. The dispatch-identity marker and its substitution note grew gsd-core/workflows/execute-phase.md by 525 bytes (91846 -> 92371), which drifts two real-tree guards that lint:ci does not run: - tests/benchmark-compact-content.test.cjs asserts the committed baseline is "up to date"; the split for execute-phase.md moved off 25827 -> 25952 and on 23576 -> 23701, taking its compaction reduction 8.72% -> 8.67%. Baseline regenerated with scripts/benchmark-compact-content.cjs --write. - tests/emitted-attribution.test.cjs requires a growth acknowledgment trailer for any emitted file that grows, keyed on the bare filename. Added below. The growth is two additions and no rewrites: the [gsd:dispatch ...] marker line inside the Agent() prompt's <objective>, and the note telling the orchestrator to substitute {plan_id} with the plan's id verbatim. Both are load-bearing -- the marker is what lets a guard hook match a dispatch to the sentinel the per-plan gate wrote, and without the note the orchestrator has no instruction telling it the value must not be paraphrased. Emitted-Drift-Ack-Growth: execute-phase.md — adds the canonical [gsd:dispatch] identity marker and its {plan_id} substitution note, which the isolation guards compare verbatim against the run-scoped sentinel (#4594) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4594): set changeset fragment pr to 4693 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
188 lines
8.3 KiB
JavaScript
188 lines
8.3 KiB
JavaScript
'use strict';
|
|
// hooks/lib/dispatch-identity.js — the ONE canonical owner of the
|
|
// `[gsd:dispatch phase="…" plan="…"]` marker format and its prose fallback
|
|
// (#4594, epic #4630 Phase 1). See
|
|
// `.gsd/phase/fix-4594-dispatch-identity-seam/40-design.md` for the full
|
|
// rationale (behavior table, negative space, rejected alternatives).
|
|
//
|
|
// COLD-LOAD CONSTRAINT (load-bearing, not a style choice): this module MUST
|
|
// require NOTHING — no `fs`, no `path`, and above all nothing under
|
|
// `gsd-core/bin/lib/` or `ensure-runtime-build`. The guard hooks that consume
|
|
// this module must load on a raw plugin-marketplace install where the
|
|
// compiled lib is absent and the self-healing build seam has not run — a
|
|
// hook that dies at module load is worse than one carrying a mirror. See
|
|
// `tests/dispatch-identity.test.cjs`'s "cold tree load" test, which asserts
|
|
// this by monkeypatching `Module._load` to throw on exactly those paths.
|
|
|
|
// DISPATCH_PHASE_TOKEN_SOURCE is a DELIBERATE MIRROR of
|
|
// `CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE` in `src/phase-id.cts`
|
|
// (ADR-2121 owns the phase-token grammar; `gsd-core/bin/lib/phase-id.cjs` is
|
|
// its compiled form). It cannot be an `require()`-based import: importing the
|
|
// compiled lib here would violate the cold-load constraint above (either a
|
|
// direct dependency on `gsd-core/bin/lib/` or a forced self-heal via
|
|
// `ensure-runtime-build`), which is exactly the failure mode this module
|
|
// exists to avoid for the guard hooks that consume it.
|
|
//
|
|
// Because this is a hand-copied mirror and not a shared reference, a
|
|
// hand-edited grammar change on one side that is not mirrored on the other
|
|
// does NOT fail loudly — both sides remain independently valid regex
|
|
// sources, so the failure mode is a silent, invisible non-match (a dispatch
|
|
// whose phase token the two owners now parse differently), never a thrown
|
|
// error. `tests/dispatch-identity.test.cjs`'s "templates: prose token source
|
|
// matches the case-flexible phase-id grammar" test pins this string equal to
|
|
// the compiled source at test time specifically to turn that silent drift
|
|
// into a loud, in-CI failure.
|
|
const DISPATCH_PHASE_TOKEN_SOURCE = '\\d+[A-Za-z]?(?:\\.\\d+)*';
|
|
|
|
// Bounded marker grammar: `[gsd:dispatch key="value" key2="value2"]`.
|
|
// - Key names bounded to {1,31} — no key we emit or expect is anywhere near
|
|
// that long; this is purely a backstop against pathological input.
|
|
// - Values bounded to {0,200} and forbidden from containing `"`, `]`, or any
|
|
// control character that could either close the marker early or forge a
|
|
// sibling key — enforced structurally by the negated character class, not
|
|
// by a separate validation pass.
|
|
// Global flag so `findMarker` can scan forward through a large prompt.
|
|
const MARKER_RE = /\[gsd:dispatch((?:\s+[A-Za-z][A-Za-z0-9_-]{0,31}="[^"\]\r\n]{0,200}")*)\s*\]/g;
|
|
const MARKER_KV_RE = /([A-Za-z][A-Za-z0-9_-]{0,31})="([^"\]\r\n]{0,200})"/g;
|
|
|
|
// Prose fallback frame: `execute plan <token> of phase <PHASE TOKEN>`.
|
|
// - Case-insensitive, `\s+` throughout so CRLF (and any run of whitespace)
|
|
// parses identically to LF.
|
|
// - The plan token is bounded (`\S{1,80}`) but its VALUE is deliberately
|
|
// discarded by the caller (see `parseDispatchIdentity` below) — captured
|
|
// only so the phase token can be anchored correctly after it.
|
|
// - The phase token is bounded by `DISPATCH_PHASE_TOKEN_SOURCE`, replacing
|
|
// the old greedy `(\S+)`, so a directory-name suffix (`-auth`) or a
|
|
// sentence-terminating period is never swept into the token.
|
|
const PROSE_RE = new RegExp(
|
|
`execute\\s+plan\\s+(\\S{1,80})\\s+of\\s+phase\\s+(${DISPATCH_PHASE_TOKEN_SOURCE})`,
|
|
'i',
|
|
);
|
|
|
|
/**
|
|
* Render the `[gsd:dispatch phase="…" plan="…"]` marker for a producer to
|
|
* embed verbatim in a dispatch description/prompt. Never throws.
|
|
*
|
|
* Emits only keys whose value is a non-empty string not containing `"`, `]`,
|
|
* `\r`, or `\n` — such a value is UNUSABLE and that key is omitted entirely,
|
|
* because embedding it could close the marker early and forge a second,
|
|
* attacker-controlled field. Key order is always `phase` then `plan`.
|
|
* Returns `''` when neither key is usable (nothing worth emitting).
|
|
*/
|
|
function renderDispatchIdentityMarker(input) {
|
|
const value = input && typeof input === 'object' ? input : {};
|
|
const parts = [];
|
|
for (const key of ['phase', 'plan']) {
|
|
const v = value[key];
|
|
if (typeof v === 'string' && v.length > 0 && !/["\]\r\n]/.test(v)) {
|
|
parts.push(`${key}="${v}"`);
|
|
}
|
|
}
|
|
if (parts.length === 0) return '';
|
|
return `[gsd:dispatch ${parts.join(' ')}]`;
|
|
}
|
|
|
|
function emptyResult() {
|
|
return { phase: null, plan: null, source: null };
|
|
}
|
|
|
|
/**
|
|
* Scan `texts` in order for the first well-formed marker that yields at
|
|
* least one recognized key (`phase` and/or `plan`). Returns `{ phase, plan }`
|
|
* (one of the two possibly null, never both) or `null` if no QUALIFYING
|
|
* marker was found in any text. Unrecognized keys inside a marker are
|
|
* ignored (forward compatibility — a later `run=`/`wave=` key must not break
|
|
* a deployed parser).
|
|
*
|
|
* #4594 F1 fix: a marker that matches `MARKER_RE` but carries neither
|
|
* `phase=` nor `plan=` (e.g. only unrecognized keys, or an empty kv block)
|
|
* is NOT treated as "found" — it is skipped and scanning continues (later
|
|
* markers in the same text, then subsequent texts), falling through to the
|
|
* prose fallback if nothing qualifying turns up. Without this, prompt text
|
|
* that merely CONTAINS the literal marker syntax with no usable identifiers
|
|
* silently suppressed the prose fallback entirely, since the old
|
|
* implementation returned on the first syntactic match regardless of
|
|
* content.
|
|
*/
|
|
function findMarker(texts) {
|
|
for (const text of texts) {
|
|
if (typeof text !== 'string' || text.length === 0) continue;
|
|
MARKER_RE.lastIndex = 0;
|
|
let match;
|
|
while ((match = MARKER_RE.exec(text)) !== null) {
|
|
const kvBlock = match[1] || '';
|
|
let phase = null;
|
|
let plan = null;
|
|
MARKER_KV_RE.lastIndex = 0;
|
|
let kv;
|
|
while ((kv = MARKER_KV_RE.exec(kvBlock)) !== null) {
|
|
const [, key, val] = kv;
|
|
if (key === 'phase' && phase === null) phase = val;
|
|
else if (key === 'plan' && plan === null) plan = val;
|
|
}
|
|
if (phase === null && plan === null) continue;
|
|
return { phase, plan };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Scan `texts` in order for the first `execute plan <token> of phase
|
|
* <PHASE TOKEN>` frame. Returns `{ phase }` or `null`.
|
|
*
|
|
* The prose fallback returns `plan: null` DELIBERATELY (enforced by the
|
|
* caller, not here): the prose plan token (e.g. `02`) is a bare in-phase
|
|
* plan number, a different namespace from the sentinel's `plan_id` (e.g.
|
|
* `03-02-hardening`, which is phase-prefixed AND slugged). Reporting the
|
|
* prose plan token as the parsed `plan` is exactly the false-mismatch bug
|
|
* this module exists to fix — an absent value is honestly "cannot compare",
|
|
* while a wrong value silently forges a mismatch on every dispatch. Do not
|
|
* "fix" this by threading the plan token through — that was the bug.
|
|
*/
|
|
function findProse(texts) {
|
|
for (const text of texts) {
|
|
if (typeof text !== 'string' || text.length === 0) continue;
|
|
const match = PROSE_RE.exec(text);
|
|
if (match) {
|
|
return { phase: match[2] };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse dispatch identity out of one or more texts (a short description, a
|
|
* full prompt body, etc). Never throws for any input type; non-string
|
|
* entries are skipped.
|
|
*
|
|
* Pass 1: scan all texts in order for a well-formed marker; the first one
|
|
* wins outright (marker beats prose even if prose appears earlier in the
|
|
* same text — see the design doc's row 8).
|
|
* Pass 2 (only if no marker was found anywhere): scan all texts in order for
|
|
* the prose frame; the first match wins. `plan` is always `null` from this
|
|
* path (see `findProse`'s doc comment).
|
|
*
|
|
* Returns `{ phase, plan, source }` where `source` is `'marker' | 'prose' |
|
|
* null`.
|
|
*/
|
|
function parseDispatchIdentity(...texts) {
|
|
const marker = findMarker(texts);
|
|
if (marker) {
|
|
return { phase: marker.phase, plan: marker.plan, source: 'marker' };
|
|
}
|
|
|
|
const prose = findProse(texts);
|
|
if (prose) {
|
|
return { phase: prose.phase, plan: null, source: 'prose' };
|
|
}
|
|
|
|
return emptyResult();
|
|
}
|
|
|
|
module.exports = {
|
|
DISPATCH_PHASE_TOKEN_SOURCE,
|
|
renderDispatchIdentityMarker,
|
|
parseDispatchIdentity,
|
|
};
|