* 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>
92 lines
4.7 KiB
JavaScript
92 lines
4.7 KiB
JavaScript
'use strict';
|
|
// hooks/lib/isolation-deny-reason.js — shared, frozen reason-code enum for
|
|
// the #3045 dispatch-isolation guards' block/deny decisions
|
|
// (hooks/gsd-agent-isolation-guard.js, hooks/gsd-cursor-subagent-start.js).
|
|
//
|
|
// CONTRIBUTING.md ("Prohibited: Raw Text Matching on Test Outputs") bans
|
|
// asserting on a hook's free-form, human-readable reason/user_message prose
|
|
// — that text is for the operator/model reading the denial and may change
|
|
// wording without notice. Every block/deny decision therefore ALSO carries
|
|
// one of these STABLE codes (surfaced on the hook's stdout JSON as
|
|
// `reason_code`), so tests assert `out.reason_code === REASON_CODE.X`
|
|
// instead of regexing the message (mirrors the REASON enum convention in
|
|
// gsd-core/bin/verify-reapply-patches.cjs).
|
|
//
|
|
// Adding a new code requires updating this enum AND any test that locks the
|
|
// documented set.
|
|
const REASON_CODE = Object.freeze({
|
|
// The compiled runtime library (gsd-core/bin/lib/*.cjs) is missing and
|
|
// could not be self-built (ensure-runtime-build.cjs's RuntimeBuildError).
|
|
RUNTIME_BUILD_FAILED: 'runtime_build_failed',
|
|
// The project's dispatch-isolation configuration ('.planning/config.json')
|
|
// could not be read or resolved for a reason OTHER than a runtime-build
|
|
// failure (unreadable/malformed config, unexpected resolver error).
|
|
CONFIG_UNREADABLE: 'config_unreadable',
|
|
// Isolation resolves to "harness-worktree" but the Agent()/Task() dispatch
|
|
// is missing the harness's isolation flag/kwarg.
|
|
HARNESS_FLAG_MISSING: 'harness_flag_missing',
|
|
// Isolation resolves to "harness-worktree" but the dispatch payload carries
|
|
// no usable subagent_type, so the guard cannot confirm it is a GSD executor.
|
|
NO_SUBAGENT_TYPE: 'no_subagent_type',
|
|
// Isolation resolves to "harness-worktree" but whether the workspace root
|
|
// is an isolated worktree could not be determined (e.g. git unresponsive).
|
|
CANNOT_DETERMINE_ISOLATION: 'cannot_determine_isolation',
|
|
// Isolation resolves to "harness-worktree" and the workspace root is
|
|
// confirmed NOT an isolated worktree.
|
|
NOT_ISOLATED_WORKTREE: 'not_isolated_worktree',
|
|
});
|
|
|
|
// #4594 row 15 / F2: values interpolated into a deny reason (sentinel/dispatch
|
|
// phase and plan) come from a sentinel file on disk and, transitively, from
|
|
// model-authored prompt text, neither of which is trusted — bound length and
|
|
// strip control characters/newlines so a crafted value cannot forge extra
|
|
// lines or otherwise inject content into the guard's stdout/stderr message
|
|
// (same discipline as escaping an untrusted token before embedding it in a
|
|
// message, e.g. phase-plan-index's `depends_on` warning).
|
|
//
|
|
// Originally duplicated byte-for-byte in hooks/gsd-agent-isolation-guard.js
|
|
// and hooks/gsd-cursor-subagent-start.js (#4594 F2 review finding) — both
|
|
// hooks already require this dependency-free module, so there is no
|
|
// cold-load justification for the duplication the way there is for
|
|
// hooks/lib/dispatch-identity.js's grammar mirror. Moved here as the single
|
|
// owner; both hooks now import it.
|
|
const REASON_INTERPOLATION_MAX_LEN = 64;
|
|
|
|
// F6: also strip Unicode line/paragraph separators (U+2028/U+2029) and the
|
|
// bidi-override/isolate control characters (U+202A-U+202E, U+2066-U+2069) —
|
|
// none of these are in `[\x00-\x1f\x7f]`, so a crafted sentinel or dispatch
|
|
// value carrying them could still visually reflow the deny message onto a
|
|
// new line or reverse/hide part of it despite the ASCII control-char strip.
|
|
const REASON_UNSAFE_CHARS_RE = new RegExp(
|
|
// eslint-disable-next-line no-control-regex -- deliberately stripping control chars/newlines
|
|
'[\\x00-\\x1f\\x7f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069]',
|
|
'g',
|
|
);
|
|
|
|
function sanitizeForReason(value) {
|
|
if (typeof value !== 'string' || value.length === 0) return '(none)';
|
|
const stripped = value.replace(REASON_UNSAFE_CHARS_RE, '');
|
|
return stripped.length > REASON_INTERPOLATION_MAX_LEN
|
|
? `${stripped.slice(0, REASON_INTERPOLATION_MAX_LEN)}…`
|
|
: stripped;
|
|
}
|
|
|
|
function describeSentinelDiscard(sentinelDiscarded) {
|
|
const sentinelPhase = sanitizeForReason(sentinelDiscarded.sentinel.phase);
|
|
const sentinelPlan = sanitizeForReason(sentinelDiscarded.sentinel.plan);
|
|
const dispatchPhase = sanitizeForReason(sentinelDiscarded.dispatch.phase);
|
|
const dispatchPlan = sanitizeForReason(sentinelDiscarded.dispatch.plan);
|
|
return (
|
|
` A fresh dispatch-isolation sentinel was present but did not apply to this dispatch ` +
|
|
`(sentinel phase="${sentinelPhase}" plan="${sentinelPlan}"; dispatch phase="${dispatchPhase}" ` +
|
|
`plan="${dispatchPlan}"), so it was not consulted.`
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
REASON_CODE,
|
|
REASON_INTERPOLATION_MAX_LEN,
|
|
sanitizeForReason,
|
|
describeSentinelDiscard,
|
|
};
|