Files
msd-core/hooks/lib/isolation-deny-reason.js
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

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/msd-agent-isolation-guard.js, hooks/msd-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
// msd-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 (msd-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 MSD 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/msd-agent-isolation-guard.js
// and hooks/msd-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,
};