1104 lines
38 KiB
TypeScript
1104 lines
38 KiB
TypeScript
// PreToolUse hook: deterministic enforcement of code-generation's
|
|
// plan-before-generation ordering (stage file Step 2-4).
|
|
//
|
|
// The stage prose says generation never begins before the human answers
|
|
// "Approve Plan": the conductor writes code-generation-plan.md, presents the
|
|
// Plan Approval question through code-generation-questions.md, and only an
|
|
// explicit approval authorizes the developer-agent dispatch. A field report
|
|
// showed prose losing that contest: a conductor generated the code first and
|
|
// backfilled the plan beside code-summary.md, making the plan an output
|
|
// instead of the input. The stage-completion artifact guard cannot catch
|
|
// this - it fires at completion time, when the backfilled plan already
|
|
// exists. Per the framework layering (determinism belongs in tools and
|
|
// hooks, knowledge in agents, judgement with humans), this hook is the
|
|
// ordering's deterministic twin.
|
|
//
|
|
// This is one of the framework's flow-altering hooks. Its contract is the
|
|
// harness-native PreToolUse block: print a reason to stderr and exit 2 to
|
|
// refuse the tool call, exit 0 to allow. The refusal is scoped tightly to
|
|
// code-generation: developer-agent dispatch and workspace mutation are both
|
|
// blocked until the same approval evidence is current. Writes inside the
|
|
// selected code-generation record dir remain available to create the plan,
|
|
// instructions, questions, and diary that make approval possible.
|
|
//
|
|
// How the hook decides: the active directive is the approval authority. A
|
|
// directive with `unit` selects construction/<unit>/code-generation; a
|
|
// zero-Unit directive selects construction/code-generation. Step 4 dispatches
|
|
// carry that choice explicitly as `AIDLC-UNIT: <unit>` or
|
|
// `AIDLC-STAGE: code-generation`, plus the exact `AIDLC-TESTING-CONTRACT`
|
|
// marker. The selected target must have a non-empty plan and test instructions,
|
|
// a structured contract matching current memory/scope/strategy/type, an
|
|
// explicit "Approve Plan" answer, and a matching approval fingerprint over
|
|
// those exact bytes. Missing, conflicting, unknown, stale, and
|
|
// post-approval-modified evidence blocks instead of guessing.
|
|
//
|
|
// Fail-open outside code-generation: a missing or unreadable state file, an
|
|
// active directive/current stage other than code-generation, malformed stdin,
|
|
// an unknown/read-only tool, a non-developer subagent target, or any throw
|
|
// allows the call. Once a code-generation generation path is identified,
|
|
// missing or ambiguous target evidence blocks. The deterministic
|
|
// off-switch AIDLC_DISABLE_PLAN_APPROVAL_GUARD=1 disables enforcement (the
|
|
// documented escape hatch for false-positive storms, mirroring the
|
|
// reviewer-scope guard's off-switch) but is no longer silent: while a workflow
|
|
// exists it appends one GUARD_DISABLED audit row per streak of disabled calls.
|
|
// Every genuine block emits a PLAN_APPROVAL_BLOCKED audit event so the run's
|
|
// record shows when the ordering bit; audit failures never change the decision.
|
|
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
import { appendAuditEntryUnlocked } from "../tools/aidlc-audit.ts";
|
|
import {
|
|
acquireAuditLock,
|
|
assertNoSymlinkInChainOrThrow,
|
|
auditBlockField,
|
|
auditFilePath,
|
|
type ClaudeCodeHookInput,
|
|
docsRoot,
|
|
errorMessage,
|
|
getField,
|
|
harnessDir,
|
|
hooksHealthDir,
|
|
isClaudeCodeHookInput,
|
|
isoTimestamp,
|
|
readActiveDirectiveMarker,
|
|
recordHookDrop,
|
|
releaseAuditLock,
|
|
resolveBoltDag,
|
|
resolveProjectFlag,
|
|
resolveProjectDirFromHook,
|
|
stateFilePath,
|
|
} from "../tools/aidlc-lib.ts";
|
|
import {
|
|
beginCodeGeneration,
|
|
codeGenerationRecordDir,
|
|
type CodeGenerationTarget,
|
|
evaluateCodeGenerationApproval,
|
|
PlanApprovalSourceDriftError,
|
|
planReviewAppendix,
|
|
promptTestingContractMarkers,
|
|
} from "../tools/aidlc-testing-posture.ts";
|
|
|
|
export {
|
|
questionsFileApproved,
|
|
questionsFileHasPendingPlanApproval,
|
|
} from "../tools/aidlc-testing-posture.ts";
|
|
|
|
const HOOK_NAME = "plan-approval-guard";
|
|
|
|
// The one stage this hook guards and the one dispatch target it inspects.
|
|
const GUARDED_STAGE = "code-generation";
|
|
const GUARDED_AGENT = "aidlc-developer-agent";
|
|
const STAGE_TARGET = "stage-level";
|
|
const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
const SAFE_READ_TOOLS = new Set([
|
|
"Read",
|
|
"Glob",
|
|
"Grep",
|
|
"WebFetch",
|
|
"WebSearch",
|
|
"TodoRead",
|
|
"TaskOutput",
|
|
"AskUserQuestion",
|
|
"fs_read",
|
|
"file_search",
|
|
"grep_search",
|
|
"thinking",
|
|
]);
|
|
const READ_ONLY_SHELL_COMMANDS = new Set([
|
|
"[",
|
|
"basename",
|
|
"cat",
|
|
"cmp",
|
|
"cut",
|
|
"diff",
|
|
"dirname",
|
|
"echo",
|
|
"file",
|
|
"grep",
|
|
"head",
|
|
"ls",
|
|
"more",
|
|
"printf",
|
|
"pwd",
|
|
"readlink",
|
|
"realpath",
|
|
"rg",
|
|
"sort",
|
|
"stat",
|
|
"tail",
|
|
"test",
|
|
"tr",
|
|
"type",
|
|
"uniq",
|
|
"wc",
|
|
"where",
|
|
"which",
|
|
]);
|
|
const TRACKED_SHELL_MUTATORS = new Set([
|
|
"cp",
|
|
"dd",
|
|
"install",
|
|
"mv",
|
|
"perl",
|
|
"rm",
|
|
"sed",
|
|
"tee",
|
|
"touch",
|
|
"truncate",
|
|
"unlink",
|
|
]);
|
|
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
"branch",
|
|
"diff",
|
|
"grep",
|
|
"log",
|
|
"ls-files",
|
|
"rev-parse",
|
|
"show",
|
|
"status",
|
|
]);
|
|
|
|
// The subagent-dispatch tool names across harness payload shapes. Claude Code
|
|
// delivers Task; the adapters translate their native dispatch tools (Kiro's
|
|
// subagent stages, opencode's task, Codex's spawn_agent) into this shape.
|
|
const DISPATCH_TOOLS = new Set(["Task", "Agent"]);
|
|
|
|
// --- The pure decision --------------------------------------------------------
|
|
//
|
|
// Everything below up to the main section is side-effect free and exported so
|
|
// the decision table is unit-testable without a live session. The hook body
|
|
// only wires stdin, the state file, and the exit code around it.
|
|
|
|
/** Per-unit evidence the main body gathers from disk. */
|
|
export interface UnitEvidence {
|
|
/** Unit-of-work name, or null for construction/code-generation stage-level work. */
|
|
unit: string | null;
|
|
/** The selected record dir's code-generation-plan.md exists and is non-empty. */
|
|
planExists: boolean;
|
|
/** unit-test-instructions.md exists and is non-empty. */
|
|
instructionsExist: boolean;
|
|
/** The unit's Plan Approval question records an explicit "Approve Plan" answer. */
|
|
approved: boolean;
|
|
/** The plan's structured Testing Contract matches the current effective posture. */
|
|
contractValid: boolean;
|
|
/** The recorded approval fingerprint matches the plan, instructions, and contract. */
|
|
fingerprintValid: boolean;
|
|
receiptValid: boolean;
|
|
/** The current approved Testing Contract hash, used to bind the worker brief. */
|
|
contractHash: string | null;
|
|
/**
|
|
* The evaluator's own sentence when the receipt is not valid. It names what
|
|
* retired the approval (a moved workspace source, an ended stage attempt, a
|
|
* changed plan) so the block text can carry the remedy instead of the generic
|
|
* "present Plan Approval" steps alone.
|
|
*/
|
|
reason?: string;
|
|
/**
|
|
* The plan's terminal `## Review` appendix, when a review recorded under the
|
|
* earlier protocol left one. The fingerprint deliberately excludes it, so it
|
|
* was never approved as work and must not appear in a developer handoff.
|
|
*/
|
|
reviewAppendix?: string;
|
|
}
|
|
|
|
/** The decision's verdict. `mentioned` carries the explicit marker value(s). */
|
|
export interface PlanApprovalVerdict {
|
|
block: boolean;
|
|
mentioned: string[];
|
|
/** The handoff carried the plan's review appendix, bytes the approval excludes. */
|
|
appendixInBrief?: boolean;
|
|
}
|
|
|
|
function approvalEvidenceIsCurrent(evidence: UnitEvidence | undefined): boolean {
|
|
return (
|
|
evidence?.planExists === true &&
|
|
evidence.instructionsExist &&
|
|
evidence.approved &&
|
|
evidence.contractValid &&
|
|
evidence.fingerprintValid &&
|
|
evidence.receiptValid &&
|
|
evidence.contractHash !== null
|
|
);
|
|
}
|
|
|
|
// Normalize a state-file stage value for comparison: the field usually holds
|
|
// the slug (code-generation) but a display-cased value (Code Generation) must
|
|
// compare equal rather than silently disable enforcement.
|
|
export function normalizeStageName(value: string): string {
|
|
return value.trim().toLowerCase().replace(/\s+/g, "-");
|
|
}
|
|
|
|
const UNIT_MARKER_RE = /^[ \t]*AIDLC-UNIT[ \t]*:[ \t]*(.*?)[ \t]*$/;
|
|
const STAGE_MARKER_RE = /^[ \t]*AIDLC-STAGE[ \t]*:[ \t]*(.*?)[ \t]*$/;
|
|
|
|
/**
|
|
* Return the distinct, non-empty target markers in encounter order. Repeated
|
|
* copies of the same marker are harmless (some harnesses carry both task and
|
|
* prompt-template text); different values are ambiguous and block.
|
|
*/
|
|
export function promptUnitMarkers(text: string): string[] {
|
|
const units = new Set<string>();
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const marker = line.match(UNIT_MARKER_RE);
|
|
const unit = marker?.[1].trim() ?? "";
|
|
if (unit.length > 0) units.add(unit);
|
|
}
|
|
return Array.from(units);
|
|
}
|
|
|
|
export function promptStageMarkers(text: string): string[] {
|
|
const stages = new Set<string>();
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const marker = line.match(STAGE_MARKER_RE);
|
|
const stage = marker?.[1].trim() ?? "";
|
|
if (stage.length > 0) stages.add(normalizeStageName(stage));
|
|
}
|
|
return Array.from(stages);
|
|
}
|
|
|
|
/**
|
|
* The plan-approval dispatch decision. Pure: no I/O, no environment.
|
|
*
|
|
* Blocks when the dispatch targets the developer agent for code-generation
|
|
* unless the prompt carries exactly one target marker (`AIDLC-UNIT` or the
|
|
* stage-level `AIDLC-STAGE: code-generation`), that marker identifies a known
|
|
* approval target, and that target has approved plan evidence.
|
|
*/
|
|
export function evaluatePlanApprovalDispatch(
|
|
toolName: string,
|
|
subagentType: string,
|
|
promptText: string,
|
|
ctx: {
|
|
currentStage: string;
|
|
units: UnitEvidence[];
|
|
},
|
|
): PlanApprovalVerdict {
|
|
const allow: PlanApprovalVerdict = { block: false, mentioned: [] };
|
|
if (!DISPATCH_TOOLS.has(toolName)) return allow;
|
|
if (subagentType !== GUARDED_AGENT) return allow;
|
|
if (normalizeStageName(ctx.currentStage) !== GUARDED_STAGE) return allow;
|
|
|
|
const markedUnits = promptUnitMarkers(promptText);
|
|
const markedStages = promptStageMarkers(promptText);
|
|
const mentioned = [
|
|
...markedUnits,
|
|
...markedStages.map((stage) => `stage:${stage}`),
|
|
];
|
|
if (markedUnits.length + markedStages.length !== 1) {
|
|
return { block: true, mentioned };
|
|
}
|
|
const target =
|
|
markedUnits.length === 1
|
|
? ctx.units.find((u) => u.unit === markedUnits[0])
|
|
: markedStages[0] === GUARDED_STAGE
|
|
? ctx.units.find((u) => u.unit === null)
|
|
: undefined;
|
|
const contractMarkers = promptTestingContractMarkers(promptText);
|
|
// The approval excludes a terminal review appendix from the plan, so a brief
|
|
// that carries those bytes hands the developer work nobody approved. The
|
|
// `brief` command produces the body-only handoff; a prompt that quotes the
|
|
// appendix is refused whether the approval is otherwise current or not.
|
|
const appendixInBrief =
|
|
target !== undefined && promptCarriesReviewAppendix(promptText, target.reviewAppendix);
|
|
return {
|
|
block:
|
|
target === undefined ||
|
|
!approvalEvidenceIsCurrent(target) ||
|
|
contractMarkers.length !== 1 ||
|
|
contractMarkers[0] !== target.contractHash ||
|
|
appendixInBrief,
|
|
mentioned,
|
|
...(appendixInBrief ? { appendixInBrief: true } : {}),
|
|
};
|
|
}
|
|
|
|
/** Whitespace-insensitive containment of a non-trivial appendix in the prompt. */
|
|
function promptCarriesReviewAppendix(
|
|
promptText: string,
|
|
appendix: string | undefined,
|
|
): boolean {
|
|
if (!appendix) return false;
|
|
const fold = (text: string): string => text.replace(/\s+/g, " ").trim();
|
|
// The heading alone is not evidence: a brief may legitimately mention that a
|
|
// review exists. The appendix's content lines are.
|
|
const content = fold(appendix.replace(/^\s*##[ \t]*Review\b[^\n]*/i, ""));
|
|
if (content.length === 0) return false;
|
|
return fold(promptText).includes(content);
|
|
}
|
|
|
|
export function appendixBlockReason(mentioned: string[]): string {
|
|
const scope =
|
|
mentioned[0] === `stage:${GUARDED_STAGE}`
|
|
? "the zero-Unit stage-level implementation"
|
|
: `unit ${mentioned[0]}`;
|
|
return (
|
|
`Code generation cannot start for ${scope} because the developer handoff carries the ` +
|
|
"plan's terminal `## Review` appendix. That appendix is excluded from the approval " +
|
|
"fingerprint, so nobody approved it as work. Hand the developer the plan BODY and the " +
|
|
"unit-test instructions only: run `aidlc-testing-posture.ts brief` for this target and " +
|
|
"pass its output verbatim, then retry the handoff."
|
|
);
|
|
}
|
|
|
|
// The block reason handed back to the conductor through the harness's
|
|
// PreToolUse error channel. Self-explaining and redirecting: it names the
|
|
// missing evidence and the exact stage steps that produce it, so the
|
|
// conductor self-corrects instead of retrying the same call.
|
|
export function blockReason(mentioned: string[], detail: string | null = null): string {
|
|
const scope =
|
|
mentioned.length === 1
|
|
? mentioned[0] === `stage:${GUARDED_STAGE}`
|
|
? "the zero-Unit stage-level implementation"
|
|
: `unit ${mentioned[0]}`
|
|
: mentioned.length > 1
|
|
? `one target, but the brief names several (${mentioned.join(", ")})`
|
|
: "one target, but the brief does not name it";
|
|
return (
|
|
`Code generation cannot start for ${scope} because its plan and test instructions are ` +
|
|
`not currently approved.${detail ? ` Reason: ${detail}.` : ""} Finish Steps 2-3 in code-generation: update ` +
|
|
`code-generation-plan.md and unit-test-instructions.md, refresh the Testing Contract and ` +
|
|
`approval fingerprint, present Plan Approval, end the turn, and wait for the human's ` +
|
|
`"Approve Plan" answer. Then retry the developer handoff with ` +
|
|
`"AIDLC-UNIT: <unit>" or "AIDLC-STAGE: code-generation", followed by ` +
|
|
`"AIDLC-TESTING-CONTRACT: <contract hash>".`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The evaluator's reason for the first mentioned target whose receipt is not
|
|
* valid, or null when every mentioned target is approved or unknown.
|
|
*/
|
|
export function receiptDetail(
|
|
evidence: UnitEvidence[],
|
|
mentioned: string[],
|
|
): string | null {
|
|
for (const name of mentioned) {
|
|
const unit = name === `stage:${GUARDED_STAGE}` ? null : name;
|
|
const match = evidence.find((entry) => entry.unit === unit);
|
|
if (match && !match.receiptValid && match.reason) return match.reason;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function mutationBlockReason(
|
|
target: string,
|
|
unit: string | null,
|
|
opaqueShell = false,
|
|
detail: string | null = null,
|
|
): string {
|
|
const scope = unit === null ? "the zero-Unit stage-level implementation" : `unit ${unit}`;
|
|
const action = opaqueShell
|
|
? `run mutation-capable ${target}`
|
|
: `modify workspace path "${target}"`;
|
|
return (
|
|
`Code generation cannot ${action} for ${scope} because ` +
|
|
`the plan, unit-test instructions, and current Testing Contract do not have a current ` +
|
|
`matching approval.${detail ? ` Reason: ${detail}.` : ""} Writes inside the selected code-generation record directory remain ` +
|
|
`available for Steps 2-3. Record the human's explicit "Approve Plan" answer before beginning ` +
|
|
`Step 4 generation.`
|
|
);
|
|
}
|
|
|
|
function authorityBlockReason(reason: string): string {
|
|
return (
|
|
"Code generation cannot start because its Plan Approval authority is ambiguous or stale. " +
|
|
`${reason}. Run a fresh \`aidlc-orchestrate.ts next\` and use that exact directive; ` +
|
|
"no stage-level fallback is permitted."
|
|
);
|
|
}
|
|
|
|
// --- Evidence gathering ---------------------------------------------------------
|
|
|
|
// The workflow's known units: the compiled bolt DAG when one resolves, plus
|
|
// every existing construction/<unit>/ dir (incremental scopes skip
|
|
// units-generation, so a conductor-chosen unit dir is the only register
|
|
// there). A malformed DAG contributes nothing - the dir listing still stands.
|
|
export function knownUnits(projectDir: string, recordDir: string): string[] {
|
|
const units = new Set<string>();
|
|
try {
|
|
const dag = resolveBoltDag(projectDir);
|
|
if (dag.state === "ok") for (const u of dag.units) units.add(u);
|
|
} catch {
|
|
// DAG resolution is best-effort here.
|
|
}
|
|
try {
|
|
const constructionDir = join(recordDir, "construction");
|
|
if (existsSync(constructionDir)) {
|
|
for (const entry of readdirSync(constructionDir, { withFileTypes: true })) {
|
|
if (entry.isDirectory() && entry.name !== GUARDED_STAGE) units.add(entry.name);
|
|
}
|
|
}
|
|
} catch {
|
|
// Unreadable construction dir - the DAG set (possibly empty) stands.
|
|
}
|
|
return Array.from(units);
|
|
}
|
|
|
|
/** The plan's terminal review appendix for a target, or undefined when it has none. */
|
|
function planAppendixFor(projectDir: string, unit: string | null): string | undefined {
|
|
try {
|
|
const plan = readFileSync(
|
|
join(codeGenerationRecordDir(projectDir, unit), "code-generation-plan.md"),
|
|
"utf-8",
|
|
);
|
|
const appendix = planReviewAppendix(plan);
|
|
return appendix.trim().length > 0 ? appendix : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function gatherUnitEvidence(projectDir: string, units: string[]): UnitEvidence[] {
|
|
return units.map((unit) => {
|
|
const approval = evaluateCodeGenerationApproval(projectDir, { unit });
|
|
const reviewAppendix = planAppendixFor(projectDir, unit);
|
|
return {
|
|
unit,
|
|
planExists: approval.planExists,
|
|
instructionsExist: approval.instructionsExist,
|
|
approved: approval.approved,
|
|
contractValid: approval.contractValid,
|
|
fingerprintValid: approval.fingerprintValid,
|
|
receiptValid: approval.receiptValid,
|
|
contractHash: approval.contractHash,
|
|
...(approval.ok ? {} : { reason: approval.reason }),
|
|
...(reviewAppendix === undefined ? {} : { reviewAppendix }),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function gatherApprovalEvidence(projectDir: string, units: string[]): UnitEvidence[] {
|
|
const stageApproval = evaluateCodeGenerationApproval(projectDir, { unit: null });
|
|
const reviewAppendix = planAppendixFor(projectDir, null);
|
|
return [
|
|
{
|
|
unit: null,
|
|
planExists: stageApproval.planExists,
|
|
instructionsExist: stageApproval.instructionsExist,
|
|
approved: stageApproval.approved,
|
|
contractValid: stageApproval.contractValid,
|
|
fingerprintValid: stageApproval.fingerprintValid,
|
|
receiptValid: stageApproval.receiptValid,
|
|
contractHash: stageApproval.contractHash,
|
|
...(stageApproval.ok ? {} : { reason: stageApproval.reason }),
|
|
...(reviewAppendix === undefined ? {} : { reviewAppendix }),
|
|
},
|
|
...gatherUnitEvidence(projectDir, units),
|
|
];
|
|
}
|
|
|
|
function isWithinDir(path: string, dir: string): boolean {
|
|
const rel = relative(dir, path);
|
|
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
}
|
|
|
|
function isTrustedRecordTarget(
|
|
projectDir: string,
|
|
target: string,
|
|
recordDir: string,
|
|
): boolean {
|
|
try {
|
|
const projectLexical = resolve(projectDir);
|
|
const projectReal = realpathSync(projectLexical);
|
|
const targetAbs = resolve(target);
|
|
const recordAbs = resolve(recordDir);
|
|
assertNoSymlinkInChainOrThrow(
|
|
projectReal,
|
|
relative(projectLexical, recordAbs),
|
|
);
|
|
assertNoSymlinkInChainOrThrow(
|
|
projectReal,
|
|
relative(projectLexical, targetAbs),
|
|
);
|
|
return isWithinDir(targetAbs, recordAbs);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
interface MutationIntent {
|
|
targets: string[];
|
|
opaqueShell: boolean;
|
|
shellCommand: string | null;
|
|
}
|
|
|
|
function normalizedCommandName(name: string): string {
|
|
return basename(name).toLowerCase().replace(/\.exe$/, "");
|
|
}
|
|
|
|
function lastFlagValue(args: string[], flag: string): string | null {
|
|
let value: string | null = null;
|
|
for (let index = 0; index < args.length; index++) {
|
|
if (args[index] !== flag) continue;
|
|
const candidate = args[index + 1];
|
|
if (!candidate || candidate.startsWith("--")) return null;
|
|
value = candidate;
|
|
index++;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isNativePlanApprovalPrerequisite(name: string, args: string[]): boolean {
|
|
const command = name.toLowerCase();
|
|
return (
|
|
(command === "aidlc" || command === "aidlc.exe") &&
|
|
isPlanApprovalPrerequisite(args)
|
|
);
|
|
}
|
|
|
|
function isPlanApprovalPrerequisite(args: string[]): boolean {
|
|
if (args[0] !== "engine") return false;
|
|
|
|
const noun = args[1];
|
|
const verb = args[2];
|
|
// The conductor re-enters through next on each human turn, and continue
|
|
// delivers the remaining stage rules. Requiring approval for that transport
|
|
// traps installations before they can finish presenting or answering it.
|
|
// Lifecycle reports and generation remain subject to the approval guard.
|
|
if (noun === "orchestrate" && (verb === "next" || verb === "continue")) {
|
|
return true;
|
|
}
|
|
if (
|
|
noun === "testing-posture" &&
|
|
["resolve", "render", "fingerprint", "verify"].includes(verb)
|
|
) {
|
|
return true;
|
|
}
|
|
if (noun !== "log" || (verb !== "decision" && verb !== "answer")) return false;
|
|
|
|
const routeArgs = args.slice(3);
|
|
return (
|
|
lastFlagValue(routeArgs, "--stage") === GUARDED_STAGE &&
|
|
lastFlagValue(routeArgs, "--checkpoint") === "plan-approval"
|
|
);
|
|
}
|
|
|
|
function gitSubcommand(args: string[]): string | null {
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (["-C", "--git-dir", "--work-tree", "--namespace"].includes(arg)) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (
|
|
arg.startsWith("--git-dir=") ||
|
|
arg.startsWith("--work-tree=") ||
|
|
arg.startsWith("--namespace=")
|
|
) {
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) continue;
|
|
return arg;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isFrameworkToolInvocation(
|
|
projectDir: string,
|
|
cwd: string,
|
|
name: string,
|
|
args: string[],
|
|
executableResolutionChanged = false,
|
|
dataDriven = false,
|
|
wrapped = false,
|
|
): boolean {
|
|
if (isNativePlanApprovalPrerequisite(name, args)) {
|
|
return !executableResolutionChanged && !dataDriven;
|
|
}
|
|
if (normalizedCommandName(name) !== "bun") return false;
|
|
if (
|
|
args.some((arg) =>
|
|
arg === "-r" ||
|
|
arg === "--require" ||
|
|
arg === "--preload" ||
|
|
arg.startsWith("--require=") ||
|
|
arg.startsWith("--preload=")
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
let scriptIndex = 0;
|
|
if (args[0] === "run") scriptIndex = 1;
|
|
const script = args[scriptIndex];
|
|
if (!script || script.startsWith("-")) return false;
|
|
const projectLexical = resolve(projectDir);
|
|
const absolute = isAbsolute(script) ? resolve(script) : resolve(cwd, script);
|
|
const trustedToolsDir = resolve(projectLexical, harnessDir(), "tools");
|
|
const unifiedEntryPoint = basename(absolute) === "aidlc.ts";
|
|
if (
|
|
dirname(absolute) !== trustedToolsDir ||
|
|
(!unifiedEntryPoint && !/^aidlc-[A-Za-z0-9._-]+\.ts$/.test(basename(absolute)))
|
|
) {
|
|
return false;
|
|
}
|
|
// The installed Bun entry point dispatches both planning and mutation routes.
|
|
// Give it the native planning exceptions only, after checking the interpreter
|
|
// and arguments. Wrappers may change cwd after parsing, so require a direct
|
|
// invocation. The same real-file/no-symlink boundary below still applies.
|
|
if (
|
|
unifiedEntryPoint &&
|
|
(
|
|
!["bun", "bun.exe"].includes(name.toLowerCase()) ||
|
|
wrapped ||
|
|
executableResolutionChanged ||
|
|
dataDriven ||
|
|
!isPlanApprovalPrerequisite(args.slice(scriptIndex + 1))
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
const projectReal = realpathSync(projectLexical);
|
|
assertNoSymlinkInChainOrThrow(
|
|
projectReal,
|
|
relative(projectLexical, absolute),
|
|
);
|
|
return lstatSync(absolute).isFile() && !lstatSync(absolute).isSymbolicLink();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function shellInvocationNeedsApproval(
|
|
projectDir: string,
|
|
cwd: string,
|
|
invocation: {
|
|
name: string;
|
|
args: string[];
|
|
executable?: string;
|
|
launchers?: string[];
|
|
dataDriven?: boolean;
|
|
executableResolutionChanged?: boolean;
|
|
},
|
|
hasConcreteTargets: boolean,
|
|
): boolean {
|
|
const name = normalizedCommandName(invocation.name);
|
|
if (name === "sort") {
|
|
return invocation.args.some(
|
|
(arg) => arg === "-o" || arg === "--output" || arg.startsWith("--output="),
|
|
);
|
|
}
|
|
if (name === "uniq") {
|
|
const operands = invocation.args.filter((arg) => !arg.startsWith("-"));
|
|
return operands.length >= 2;
|
|
}
|
|
if (READ_ONLY_SHELL_COMMANDS.has(name)) return false;
|
|
if (name === "git") {
|
|
if (
|
|
invocation.args.some(
|
|
(arg) => arg === "--output" || arg.startsWith("--output="),
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
const subcommand = gitSubcommand(invocation.args);
|
|
if (subcommand === "branch") {
|
|
return !invocation.args.includes("--show-current");
|
|
}
|
|
return subcommand === null || !READ_ONLY_GIT_SUBCOMMANDS.has(subcommand);
|
|
}
|
|
if (
|
|
isFrameworkToolInvocation(
|
|
projectDir,
|
|
cwd,
|
|
invocation.executable ?? invocation.name,
|
|
invocation.args,
|
|
invocation.executableResolutionChanged,
|
|
invocation.dataDriven,
|
|
(invocation.launchers?.length ?? 0) > 0,
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
TRACKED_SHELL_MUTATORS.has(name) &&
|
|
hasConcreteTargets &&
|
|
!invocation.args.some((arg) => /[$`*?]/.test(arg))
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function shellUsesDynamicEvaluation(command: string): boolean {
|
|
let quote: "'" | '"' | null = null;
|
|
let escaped = false;
|
|
for (let i = 0; i < command.length; i++) {
|
|
const ch = command[i];
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (ch === "\\" && quote !== "'") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote === "'") {
|
|
if (ch === "'") quote = null;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
quote = quote === '"' ? null : '"';
|
|
continue;
|
|
}
|
|
if (ch === "'" && quote === null) {
|
|
quote = "'";
|
|
continue;
|
|
}
|
|
if (ch === "`" || ch === "$") return true;
|
|
if ((ch === "$" || ch === "<" || ch === ">") && command[i + 1] === "(") {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function mutationIntent(
|
|
projectDir: string,
|
|
toolName: string,
|
|
toolInput: Record<string, unknown> | undefined,
|
|
cwd: string,
|
|
): Promise<MutationIntent> {
|
|
let targets: string[] = [];
|
|
let opaqueShell = false;
|
|
let shellCommand: string | null = null;
|
|
if (toolName === "Bash") {
|
|
const command = toolInput?.command;
|
|
if (typeof command !== "string") {
|
|
return { targets: [], opaqueShell: false, shellCommand: null };
|
|
}
|
|
shellCommand = command;
|
|
const {
|
|
shellCommandAltersExecutableResolution,
|
|
shellCommandInvocationDetails,
|
|
shellWriteTargets,
|
|
} = await import("./aidlc-review-freeze.ts");
|
|
targets = shellWriteTargets(command, cwd);
|
|
opaqueShell =
|
|
shellUsesDynamicEvaluation(command) ||
|
|
shellCommandAltersExecutableResolution(command) ||
|
|
shellCommandInvocationDetails(command).some((invocation) =>
|
|
shellInvocationNeedsApproval(projectDir, cwd, invocation, targets.length > 0)
|
|
);
|
|
} else if (WRITE_TOOLS.has(toolName)) {
|
|
const input = toolInput ?? {};
|
|
const add = (value: unknown) => {
|
|
if (typeof value === "string" && value.length > 0) targets.push(value);
|
|
};
|
|
add(input.file_path);
|
|
add(input.notebook_path);
|
|
add(input.path);
|
|
if (Array.isArray(input.paths)) for (const path of input.paths) add(path);
|
|
}
|
|
return {
|
|
targets: targets.map((target) =>
|
|
isAbsolute(target) ? resolve(target) : resolve(cwd, target)
|
|
),
|
|
opaqueShell,
|
|
shellCommand,
|
|
};
|
|
}
|
|
|
|
// --- Main ---------------------------------------------------------------------
|
|
|
|
// The off-switch is deterministic but no longer silent: while a workflow exists,
|
|
// the first tool call that passes under it appends one GUARD_DISABLED row, and
|
|
// consecutive calls append nothing until some other row lands in the active
|
|
// shard. Every failure in this bookkeeping still allows the call.
|
|
function recordGuardDisabled(input: string): void {
|
|
const projectDir = resolveProjectDirFromHook(import.meta.url);
|
|
if (!existsSync(stateFilePath(projectDir))) return;
|
|
let toolName = "";
|
|
try {
|
|
const raw: unknown = JSON.parse(input);
|
|
if (isClaudeCodeHookInput(raw) && typeof raw.tool_name === "string") {
|
|
toolName = raw.tool_name;
|
|
}
|
|
} catch {
|
|
// The row still says the guard was off; the tool name is best-effort.
|
|
}
|
|
const shardPath = auditFilePath(projectDir);
|
|
if (existsSync(shardPath)) {
|
|
const blocks = readFileSync(shardPath, "utf-8")
|
|
.replace(/\r\n/g, "\n")
|
|
.split(/\n---\n/);
|
|
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
const event = auditBlockField(blocks[index], "Event");
|
|
if (event === null) continue;
|
|
if (event === "GUARD_DISABLED" && auditBlockField(blocks[index], "Guard") === HOOK_NAME) {
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (!acquireAuditLock(projectDir, 5, 50)) return;
|
|
try {
|
|
appendAuditEntryUnlocked(
|
|
"GUARD_DISABLED",
|
|
{ Guard: HOOK_NAME, Tool: toolName || "(unknown)" },
|
|
projectDir,
|
|
);
|
|
} finally {
|
|
releaseAuditLock(projectDir);
|
|
}
|
|
}
|
|
|
|
export async function run(input: string): Promise<number> {
|
|
// Deterministic off-switch: enforcement disabled entirely, recorded once.
|
|
if (resolveProjectFlag("AIDLC_DISABLE_PLAN_APPROVAL_GUARD") === "1") {
|
|
try {
|
|
recordGuardDisabled(input);
|
|
} catch {
|
|
// Fail-open: the off-switch always allows.
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
const projectDir = resolveProjectDirFromHook(import.meta.url);
|
|
|
|
try {
|
|
const healthDir = hooksHealthDir(projectDir);
|
|
mkdirSync(healthDir, { recursive: true });
|
|
writeFileSync(join(healthDir, `${HOOK_NAME}.last`), isoTimestamp(), "utf-8");
|
|
} catch {
|
|
// Heartbeat failure is non-fatal - never let it affect the decision.
|
|
}
|
|
|
|
// A TTY means no harness JSON is coming (test / debug contexts) - allow.
|
|
if (process.stdin.isTTY) return 0;
|
|
|
|
let parsed: ClaudeCodeHookInput;
|
|
try {
|
|
const raw: unknown = JSON.parse(input);
|
|
if (!isClaudeCodeHookInput(raw)) return 0;
|
|
parsed = raw;
|
|
} catch {
|
|
return 0; // malformed stdin - fail open
|
|
}
|
|
|
|
const toolName = parsed.tool_name ?? "";
|
|
const toolInput = parsed.tool_input ?? {};
|
|
const subagentType =
|
|
typeof toolInput.subagent_type === "string" ? toolInput.subagent_type : "";
|
|
const guardedDispatch =
|
|
DISPATCH_TOOLS.has(toolName) && subagentType === GUARDED_AGENT;
|
|
if (SAFE_READ_TOOLS.has(toolName)) return 0;
|
|
const mutationCapable =
|
|
toolName === "Bash" ||
|
|
WRITE_TOOLS.has(toolName) ||
|
|
(!DISPATCH_TOOLS.has(toolName) && toolName.length > 0);
|
|
if (!guardedDispatch && !mutationCapable) return 0;
|
|
const cwd = typeof parsed.cwd === "string" ? parsed.cwd : projectDir;
|
|
|
|
let verdict: PlanApprovalVerdict;
|
|
let units: UnitEvidence[] = [];
|
|
let authorityFailure: string | null = null;
|
|
let blockedMutation: {
|
|
target: string;
|
|
unit: string | null;
|
|
opaqueShell: boolean;
|
|
detail: string | null;
|
|
} | null = null;
|
|
try {
|
|
const statePath = stateFilePath(projectDir);
|
|
if (!existsSync(statePath)) return 0; // no workflow - fail open
|
|
const state = readFileSync(statePath, "utf-8");
|
|
const currentStage = getField(state, "Current Stage") ?? "";
|
|
const activeDirective = readActiveDirectiveMarker(projectDir, state);
|
|
const durableStage = normalizeStageName(currentStage);
|
|
const directiveStage = normalizeStageName(activeDirective?.stage ?? "");
|
|
const dispatchPrompt = [toolInput.prompt, toolInput.description]
|
|
.filter((value): value is string => typeof value === "string")
|
|
.join("\n");
|
|
const explicitPlanDispatch =
|
|
promptUnitMarkers(dispatchPrompt).length > 0 ||
|
|
promptStageMarkers(dispatchPrompt).length > 0 ||
|
|
promptTestingContractMarkers(dispatchPrompt).length > 0;
|
|
const codeGenerationRelevant =
|
|
directiveStage === GUARDED_STAGE ||
|
|
durableStage === GUARDED_STAGE ||
|
|
(guardedDispatch && explicitPlanDispatch);
|
|
if (!codeGenerationRelevant) return 0;
|
|
const knownMutationTool =
|
|
toolName === "Bash" || WRITE_TOOLS.has(toolName);
|
|
const mutation = guardedDispatch
|
|
? { targets: [], opaqueShell: false, shellCommand: null }
|
|
: knownMutationTool
|
|
? await mutationIntent(projectDir, toolName, toolInput, cwd)
|
|
: {
|
|
targets: [],
|
|
opaqueShell: true,
|
|
shellCommand: `unknown mutation-capable tool: ${toolName}`,
|
|
};
|
|
if (!guardedDispatch && mutation.targets.length === 0 && !mutation.opaqueShell) {
|
|
return 0;
|
|
}
|
|
|
|
if (
|
|
activeDirective?.version !== 2 ||
|
|
directiveStage !== GUARDED_STAGE
|
|
) {
|
|
authorityFailure =
|
|
"the current state has no matching v2 code-generation active directive";
|
|
verdict = { block: true, mentioned: [] };
|
|
} else {
|
|
const recordDir = docsRoot(projectDir);
|
|
units = gatherApprovalEvidence(projectDir, knownUnits(projectDir, recordDir));
|
|
if (guardedDispatch) {
|
|
verdict = evaluatePlanApprovalDispatch(toolName, subagentType, dispatchPrompt, {
|
|
currentStage: activeDirective.stage,
|
|
units,
|
|
});
|
|
} else if (activeDirective.kind !== "run-stage") {
|
|
authorityFailure =
|
|
`workspace mutation cannot select one approval target from directive kind "${activeDirective.kind}"`;
|
|
verdict = { block: true, mentioned: [] };
|
|
} else {
|
|
const unit = activeDirective.unit?.trim() || null;
|
|
const target: CodeGenerationTarget = { unit };
|
|
const approvalDir = resolve(codeGenerationRecordDir(projectDir, unit));
|
|
const outsideRecord = mutation.targets.find(
|
|
(candidate) =>
|
|
!isTrustedRecordTarget(projectDir, candidate, approvalDir),
|
|
);
|
|
if (!outsideRecord && !mutation.opaqueShell) return 0;
|
|
const approval = evaluateCodeGenerationApproval(projectDir, target);
|
|
const evidence: UnitEvidence = {
|
|
unit,
|
|
planExists: approval.planExists,
|
|
instructionsExist: approval.instructionsExist,
|
|
approved: approval.approved,
|
|
contractValid: approval.contractValid,
|
|
fingerprintValid: approval.fingerprintValid,
|
|
receiptValid: approval.receiptValid,
|
|
contractHash: approval.contractHash,
|
|
...(approval.ok ? {} : { reason: approval.reason }),
|
|
};
|
|
verdict = {
|
|
block: !approvalEvidenceIsCurrent(evidence),
|
|
mentioned: [unit ?? `stage:${GUARDED_STAGE}`],
|
|
};
|
|
if (verdict.block) {
|
|
blockedMutation = {
|
|
target:
|
|
outsideRecord ??
|
|
`shell command: ${(mutation.shellCommand ?? "").trim().slice(0, 160)}`,
|
|
unit,
|
|
opaqueShell: outsideRecord === undefined,
|
|
detail: receiptDetail([evidence], verdict.mentioned),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
recordHookDrop(projectDir, HOOK_NAME, errorMessage(e));
|
|
authorityFailure =
|
|
`Plan Approval authority evaluation failed closed: ${errorMessage(e)}`;
|
|
verdict = { block: true, mentioned: [] };
|
|
}
|
|
if (!verdict.block) {
|
|
// Under Change Control `relaxed`, generation start may accept source that
|
|
// moved after approval: the ledger row is written there and the one human
|
|
// line comes back to be printed on this hook's stdout.
|
|
const changeNotices: string[] = [];
|
|
try {
|
|
if (guardedDispatch) {
|
|
for (const mentioned of verdict.mentioned) {
|
|
changeNotices.push(
|
|
...beginCodeGeneration(projectDir, {
|
|
unit:
|
|
mentioned === `stage:${GUARDED_STAGE}` ? null : mentioned,
|
|
}),
|
|
);
|
|
}
|
|
} else if (blockedMutation === null) {
|
|
const state = readFileSync(stateFilePath(projectDir), "utf-8");
|
|
const marker = readActiveDirectiveMarker(projectDir, state);
|
|
if (marker?.version === 2 && marker.kind === "run-stage") {
|
|
changeNotices.push(
|
|
...beginCodeGeneration(projectDir, {
|
|
unit: marker.unit?.trim() || null,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
authorityFailure =
|
|
`Code Generation could not start from its protected approval receipt: ${errorMessage(e)}` +
|
|
(e instanceof PlanApprovalSourceDriftError ? ` ${e.remedy}` : "");
|
|
verdict = { block: true, mentioned: verdict.mentioned };
|
|
}
|
|
if (!verdict.block) {
|
|
for (const notice of changeNotices) process.stdout.write(`${notice}\n`);
|
|
}
|
|
}
|
|
if (!verdict.block) return 0;
|
|
|
|
// Audit the refusal so the run's record shows when the ordering bit.
|
|
// Best-effort: an audit failure never changes the block decision. The lock
|
|
// acquisition is TIME-BOUNDED well below the standard 5s budget (5 x 50ms):
|
|
// the block decision is already made, and a dropped advisory row is
|
|
// preferable to a slow block.
|
|
try {
|
|
if (existsSync(auditFilePath(projectDir))) {
|
|
if (acquireAuditLock(projectDir, 5, 50)) {
|
|
try {
|
|
appendAuditEntryUnlocked(
|
|
"PLAN_APPROVAL_BLOCKED",
|
|
{
|
|
Tool: toolName,
|
|
Target: guardedDispatch ? subagentType : blockedMutation?.target ?? "",
|
|
Stage: GUARDED_STAGE,
|
|
Unit:
|
|
blockedMutation?.unit ??
|
|
(verdict.mentioned[0] === `stage:${GUARDED_STAGE}`
|
|
? STAGE_TARGET
|
|
: verdict.mentioned.join(", ") || "(missing marker)"),
|
|
},
|
|
projectDir,
|
|
);
|
|
} finally {
|
|
releaseAuditLock(projectDir);
|
|
}
|
|
} else {
|
|
recordHookDrop(
|
|
projectDir,
|
|
HOOK_NAME,
|
|
"audit lock contended; PLAN_APPROVAL_BLOCKED row dropped (block still enforced)",
|
|
);
|
|
}
|
|
}
|
|
} catch {
|
|
// Advisory emission only.
|
|
}
|
|
|
|
process.stderr.write(
|
|
`${authorityFailure
|
|
? authorityBlockReason(authorityFailure)
|
|
: blockedMutation
|
|
? mutationBlockReason(
|
|
blockedMutation.target,
|
|
blockedMutation.unit,
|
|
blockedMutation.opaqueShell,
|
|
blockedMutation.detail,
|
|
)
|
|
: verdict.appendixInBrief
|
|
? appendixBlockReason(verdict.mentioned)
|
|
: blockReason(verdict.mentioned, receiptDetail(units, verdict.mentioned))}\n`,
|
|
);
|
|
return 2; // harness PreToolUse reject contract: exit 2 + stderr blocks
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const input = process.stdin.isTTY ? "" : await Bun.stdin.text();
|
|
process.exit(await run(input));
|
|
}
|