intentdna 1.8.0 → 1.8.2
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/commands/run.d.ts +2 -0
- package/dist/cli/commands/run.js +43 -40
- package/dist/hooks/cli.d.ts +4 -0
- package/dist/hooks/cli.js +21 -406
- package/dist/runtime/skill-adapter.js +44 -5
- package/dist/runtime/verifier.d.ts +51 -0
- package/dist/runtime/verifier.js +462 -0
- package/dist/templates/flutter-rewrite.dna.yaml +80 -4
- package/package.json +1 -1
|
@@ -107,11 +107,12 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
|
|
|
107
107
|
lines.push("2. Create a stable controller_run_id once for this invocation before using any artifact path.");
|
|
108
108
|
lines.push(" - Reuse the same controller_run_id in every child Agent prompt and in every progress validation.");
|
|
109
109
|
lines.push(" - Do not regenerate controller_run_id inside fix rounds.");
|
|
110
|
-
lines.push("3. Reuse diagnosis only when all diagnosis reuse criteria pass:");
|
|
110
|
+
lines.push("3. Reuse the diagnosis artifact only when all diagnosis reuse criteria pass:");
|
|
111
111
|
for (const criterion of policy.diagnosis_reuse_criteria) {
|
|
112
112
|
lines.push(` - ${criterion}`);
|
|
113
113
|
}
|
|
114
|
-
lines.push(
|
|
114
|
+
lines.push(" Reuse skips fresh analyzer and analysis_reviewer agents; it never skips the declared diagnosis contract completion gates or the persisted diagnosis review JSON approval gate before any surgeon step.");
|
|
115
|
+
lines.push(`4. If diagnosis is missing, invalid, stale, or scope-changed, run the analyzer once with fresh context:`);
|
|
115
116
|
lines.push(" a. Spawn fresh analyzer agent with controller_run_id, then wait for completion.");
|
|
116
117
|
lines.push(" ```");
|
|
117
118
|
lines.push(" Agent(");
|
|
@@ -119,6 +120,7 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
|
|
|
119
120
|
lines.push(` prompt="controller_run_id=<controller_run_id>. ${escapePrompt(stepPrompt(diagnosisAnalyzeStep))}"`);
|
|
120
121
|
lines.push(" )");
|
|
121
122
|
lines.push(" ```");
|
|
123
|
+
pushWorkflowStepVerifierLines(lines, diagnosisAnalyzeStep, " ");
|
|
122
124
|
lines.push(` b. Diagnosis artifact gate: read ${controller.diagnosis_artifact_path}; validate it exists, is non-empty, names the safe module argument, and is current for this module before continuing.`);
|
|
123
125
|
lines.push(" c. Spawn fresh analysis_reviewer agent with controller_run_id, then wait for completion.");
|
|
124
126
|
lines.push(" ```");
|
|
@@ -127,9 +129,12 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
|
|
|
127
129
|
lines.push(` prompt="controller_run_id=<controller_run_id>; expected_review_artifact=${controller.diagnosis_review_artifact_path}. ${escapePrompt(stepPrompt(diagnosisReviewStep))}"`);
|
|
128
130
|
lines.push(" )");
|
|
129
131
|
lines.push(" ```");
|
|
130
|
-
lines
|
|
132
|
+
pushWorkflowStepVerifierLines(lines, diagnosisReviewStep, " ");
|
|
133
|
+
lines.push("5. If the diagnosis artifact was reused, do not spawn analyzer or analysis_reviewer; rerun the declared diagnosis contract completion gates against the existing artifacts:");
|
|
134
|
+
pushWorkflowStepVerifierLines(lines, diagnosisReviewStep, " ");
|
|
135
|
+
lines.push(`6. Diagnosis review JSON gate: read ${controller.diagnosis_review_artifact_path}; require existing valid JSON with verdict APPROVE before fix rounds.`);
|
|
131
136
|
lines.push(" If the review JSON is missing, invalid, or verdict is not APPROVE, stop immediately with the configured stop report; do NOT enter fix rounds.");
|
|
132
|
-
lines.push(`
|
|
137
|
+
lines.push(`7. For round 1..${controller.max_rounds}, set current_round to the loop number and run one fresh context fix round with sequential gates:`);
|
|
133
138
|
lines.push(" a. Spawn fresh surgeon agent with controller_run_id and current_round, then wait for completion.");
|
|
134
139
|
lines.push(" ```");
|
|
135
140
|
lines.push(" Agent(");
|
|
@@ -137,6 +142,7 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
|
|
|
137
142
|
lines.push(` prompt="controller_run_id=<controller_run_id>; current_round=<current_round>. Fresh context round for $ARGUMENTS. ${escapePrompt(stepPrompt(fixBugsStep))}"`);
|
|
138
143
|
lines.push(" )");
|
|
139
144
|
lines.push(" ```");
|
|
145
|
+
pushWorkflowStepVerifierLines(lines, fixBugsStep, " ");
|
|
140
146
|
lines.push(" b. Fix handoff gate: validate the current round handoff and fix commit, or an explicit no-commit handoff for current_round, before review.");
|
|
141
147
|
lines.push(" c. Spawn fresh fix_reviewer agent with controller_run_id, current_round, and expected review artifact path, then wait for completion.");
|
|
142
148
|
lines.push(" ```");
|
|
@@ -145,6 +151,7 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
|
|
|
145
151
|
lines.push(` prompt="controller_run_id=<controller_run_id>; current_round=<current_round>; expected_review_artifact=${controller.review_artifact_path}. Fresh context review for $ARGUMENTS. ${escapePrompt(stepPrompt(reviewChangesStep))}"`);
|
|
146
152
|
lines.push(" )");
|
|
147
153
|
lines.push(" ```");
|
|
154
|
+
pushWorkflowStepVerifierLines(lines, reviewChangesStep, " ");
|
|
148
155
|
lines.push(` d. Review JSON gate: read ${controller.review_artifact_path}; validate it exists, is valid JSON, module equals the safe module argument, round equals the provided current_round, run_id equals the provided controller_run_id, updated_at is present, and verdict is APPROVE or REQUEST_CHANGES before verification.`);
|
|
149
156
|
lines.push(" e. Spawn fresh test_runner agent only after the review gate passes; include controller_run_id and current_round, then wait for completion.");
|
|
150
157
|
lines.push(" ```");
|
|
@@ -153,6 +160,7 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
|
|
|
153
160
|
lines.push(` prompt="controller_run_id=<controller_run_id>; current_round=<current_round>. Fresh context verification for $ARGUMENTS. The progress JSON run_id must equal the provided controller_run_id and round must equal the provided current_round; do not use manual-run during controller execution. ${escapePrompt(stepPrompt(verifyReportStep))}"`);
|
|
154
161
|
lines.push(" )");
|
|
155
162
|
lines.push(" ```");
|
|
163
|
+
pushWorkflowStepVerifierLines(lines, verifyReportStep, " ");
|
|
156
164
|
lines.push(` f. Progress JSON read/provenance validation: read ${controller.progress_artifact_path}. The controller must read this JSON sidecar, not Markdown prose.`);
|
|
157
165
|
lines.push(" g. Verify progress JSON provenance before branching:");
|
|
158
166
|
for (const check of policy.progress_provenance_checks) {
|
|
@@ -163,7 +171,7 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
|
|
|
163
171
|
for (const branch of policy.verdict_branches) {
|
|
164
172
|
lines.push(` - ${branch}`);
|
|
165
173
|
}
|
|
166
|
-
lines.push(`
|
|
174
|
+
lines.push(`8. If max ${controller.max_rounds} rounds is reached, stop with the configured stop report.`);
|
|
167
175
|
lines.push("</Steps>");
|
|
168
176
|
lines.push("");
|
|
169
177
|
lines.push("<Verdict_Mapping>");
|
|
@@ -223,6 +231,37 @@ function requireStepByRole(workflow, role, workflowName) {
|
|
|
223
231
|
}
|
|
224
232
|
return step;
|
|
225
233
|
}
|
|
234
|
+
function pushWorkflowStepVerifierLines(lines, step, indent) {
|
|
235
|
+
if (step.completion?.length) {
|
|
236
|
+
lines.push(`${indent}Completion gates for workflow step '${step.id}' must pass in declaration order before treating the step as complete:`);
|
|
237
|
+
for (let i = 0; i < step.completion.length; i++) {
|
|
238
|
+
lines.push(`${indent} ${i + 1}. ${describeCompletionCheck(step.completion[i])}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (step.checkpoints?.length) {
|
|
242
|
+
lines.push(`${indent}Checkpoint gates for workflow step '${step.id}' must pass in declaration order before transitioning:`);
|
|
243
|
+
for (let i = 0; i < step.checkpoints.length; i++) {
|
|
244
|
+
const checkpoint = step.checkpoints[i];
|
|
245
|
+
const action = checkpoint.action ?? "block";
|
|
246
|
+
const command = checkpoint.command ? `; command: ${checkpoint.command}` : "";
|
|
247
|
+
lines.push(`${indent} ${i + 1}. ${checkpoint.assert}: ${checkpoint.message} (${action}${command})`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (step.completion?.some((check) => check.command_success) || step.checkpoints?.some((check) => check.command)) {
|
|
251
|
+
lines.push(`${indent}Command-backed completion/checkpoint gates must use runtime verifier_policy semantics: first authorize the declared raw command against exact allow_commands or allow_command_prefixes, then resolve templates safely, execute the exact resolved command, require exit code 0 for pass, treat policy denial as exit_code=126, and treat any non-zero exit code as blocking failure unless the checkpoint action is warn.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function describeCompletionCheck(check) {
|
|
255
|
+
if (check.file_exists)
|
|
256
|
+
return `file_exists: ${check.file_exists}`;
|
|
257
|
+
if (check.file_not_empty)
|
|
258
|
+
return `file_not_empty: ${check.file_not_empty}`;
|
|
259
|
+
if (check.file_contains)
|
|
260
|
+
return `file_contains: ${check.file_contains.path} matches ${check.file_contains.pattern}`;
|
|
261
|
+
if (check.command_success)
|
|
262
|
+
return `command_success: execute exact command "${check.command_success}", require exit code 0, and block on any non-zero exit code`;
|
|
263
|
+
return "invalid completion check: exactly one completion check field is required";
|
|
264
|
+
}
|
|
226
265
|
function createSkillLauncher(params) {
|
|
227
266
|
const lines = [];
|
|
228
267
|
lines.push("---");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CompletionCheck, ConstraintIR, StepCheckpoint, VerifierCommandPolicy } from "../schema/types.js";
|
|
2
|
+
export declare const DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS = 30000;
|
|
3
|
+
export declare const MAX_VERIFIER_EVIDENCE_BYTES = 4096;
|
|
4
|
+
export interface VerifierRuntimeContext {
|
|
5
|
+
projectDir: string;
|
|
6
|
+
variables?: Record<string, string>;
|
|
7
|
+
commandTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface VerifierCheckResult {
|
|
10
|
+
passed: boolean;
|
|
11
|
+
target?: string;
|
|
12
|
+
evidence?: string;
|
|
13
|
+
exit_code?: number;
|
|
14
|
+
artifact?: string;
|
|
15
|
+
message?: string;
|
|
16
|
+
timedOut?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function resolveVerifierTemplate(template: string, variables?: Record<string, string>): string;
|
|
19
|
+
export declare function verifierTemplateVariableNames(template: string): string[];
|
|
20
|
+
export declare function validateVerifierTemplateVariables(template: string, variables?: Record<string, string>, mode?: "command" | "path"): {
|
|
21
|
+
valid: true;
|
|
22
|
+
} | {
|
|
23
|
+
valid: false;
|
|
24
|
+
variable: string;
|
|
25
|
+
evidence: string;
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function hasUnresolvedVerifierTemplate(value: string): boolean;
|
|
29
|
+
export declare function unresolvedVerifierTemplateMessage(value: string): string;
|
|
30
|
+
export declare function hasUnsafeShellControl(command: string): boolean;
|
|
31
|
+
export declare function isVerifierCommandAllowed(policy: VerifierCommandPolicy | undefined, command: string): boolean;
|
|
32
|
+
export declare function verifierCommandPolicyMessage(command: string): string;
|
|
33
|
+
export declare function isBuiltinAssertAllowed(policy: VerifierCommandPolicy | undefined, assertName: string): boolean;
|
|
34
|
+
export declare function verifierAssertPolicyMessage(assertName: string): string;
|
|
35
|
+
export declare function trimVerifierEvidence(raw: string | undefined): string | undefined;
|
|
36
|
+
export declare function summarizeCommandEvidence(stdout: string, stderr: string): string | undefined;
|
|
37
|
+
export declare function execVerifierCommand(projectDir: string, command: string, timeoutMs?: number, env?: NodeJS.ProcessEnv): Promise<{
|
|
38
|
+
passed: boolean;
|
|
39
|
+
timedOut: boolean;
|
|
40
|
+
exitCode?: number;
|
|
41
|
+
evidence?: string;
|
|
42
|
+
}>;
|
|
43
|
+
export declare function resolveVerifierTarget(projectDir: string, targetPath: string): Promise<string | null>;
|
|
44
|
+
export declare function toProjectRelativePath(projectDir: string, filePath: string): Promise<string>;
|
|
45
|
+
export declare function getCompletionCheckFields(completion: CompletionCheck): string[];
|
|
46
|
+
export declare function validateCompletionVerifier(completion: CompletionCheck): {
|
|
47
|
+
message: string;
|
|
48
|
+
evidence: string;
|
|
49
|
+
} | null;
|
|
50
|
+
export declare function runCompletionVerifier(completion: CompletionCheck, ir: Pick<ConstraintIR, "verifier_policy">, context: VerifierRuntimeContext): Promise<VerifierCheckResult>;
|
|
51
|
+
export declare function runCheckpointVerifier(checkpoint: StepCheckpoint, ir: Pick<ConstraintIR, "verifier_policy">, context: VerifierRuntimeContext): Promise<VerifierCheckResult>;
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
export const DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS = 30_000;
|
|
5
|
+
export const MAX_VERIFIER_EVIDENCE_BYTES = 4096;
|
|
6
|
+
const SAFE_COMMAND_VARIABLE_RE = /^[A-Za-z0-9_-]+$/;
|
|
7
|
+
const SAFE_PATH_VARIABLE_RE = /^[-A-Za-z0-9_./]+$/;
|
|
8
|
+
export function resolveVerifierTemplate(template, variables) {
|
|
9
|
+
let resolved = template;
|
|
10
|
+
if (variables?.ARGUMENTS !== undefined) {
|
|
11
|
+
resolved = resolved.replace(/(^|[^\\])\$ARGUMENTS/g, (_match, prefix) => `${prefix}${variables.ARGUMENTS}`);
|
|
12
|
+
}
|
|
13
|
+
resolved = resolved.replace(/\{\{(\w+)\}\}/g, (match, key) => variables?.[key] ?? match);
|
|
14
|
+
return resolved;
|
|
15
|
+
}
|
|
16
|
+
export function verifierTemplateVariableNames(template) {
|
|
17
|
+
const names = new Set();
|
|
18
|
+
if (/(^|[^\\])\$ARGUMENTS/.test(template))
|
|
19
|
+
names.add("ARGUMENTS");
|
|
20
|
+
for (const match of template.matchAll(/\{\{(\w+)\}\}/g))
|
|
21
|
+
names.add(match[1]);
|
|
22
|
+
return [...names];
|
|
23
|
+
}
|
|
24
|
+
function isWholeVerifierCommandTemplate(command) {
|
|
25
|
+
return /^\{\{\w+\}\}$/.test(command.trim()) || command.trim() === "$ARGUMENTS";
|
|
26
|
+
}
|
|
27
|
+
export function validateVerifierTemplateVariables(template, variables, mode = "command") {
|
|
28
|
+
for (const name of verifierTemplateVariableNames(template)) {
|
|
29
|
+
const value = variables?.[name];
|
|
30
|
+
if (value === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
const matchesSafeCharacters = mode === "path"
|
|
33
|
+
? SAFE_PATH_VARIABLE_RE.test(value) && !value.startsWith("/") && !value.includes("//") && !value.split(/[\\/]+/).includes("..")
|
|
34
|
+
: SAFE_COMMAND_VARIABLE_RE.test(value);
|
|
35
|
+
if (!matchesSafeCharacters) {
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
variable: name,
|
|
39
|
+
evidence: `unsafe_variable:${name}`,
|
|
40
|
+
message: `Verifier template variable '${name}' contains unsafe ${mode} characters`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { valid: true };
|
|
45
|
+
}
|
|
46
|
+
function resolveVerifierCommand(rawCommand, policy, variables) {
|
|
47
|
+
const declaredCommand = rawCommand.trim();
|
|
48
|
+
if (isWholeVerifierCommandTemplate(declaredCommand)) {
|
|
49
|
+
const command = resolveVerifierTemplate(declaredCommand, variables).trim();
|
|
50
|
+
if (hasUnresolvedVerifierTemplate(command)) {
|
|
51
|
+
return { target: command, evidence: "unresolved_template", exit_code: 126, message: unresolvedVerifierTemplateMessage(command) };
|
|
52
|
+
}
|
|
53
|
+
if (!isVerifierCommandAllowed(policy, command)) {
|
|
54
|
+
return { target: command, evidence: "policy_denied", exit_code: 126, message: verifierCommandPolicyMessage(command) };
|
|
55
|
+
}
|
|
56
|
+
return { command };
|
|
57
|
+
}
|
|
58
|
+
if (!isVerifierCommandAllowed(policy, declaredCommand)) {
|
|
59
|
+
return { target: declaredCommand, evidence: "policy_denied", exit_code: 126, message: verifierCommandPolicyMessage(declaredCommand) };
|
|
60
|
+
}
|
|
61
|
+
const safeVariables = validateVerifierTemplateVariables(declaredCommand, variables, "command");
|
|
62
|
+
if (!safeVariables.valid) {
|
|
63
|
+
return { target: declaredCommand, evidence: safeVariables.evidence, exit_code: 126, message: safeVariables.message };
|
|
64
|
+
}
|
|
65
|
+
const command = resolveVerifierTemplate(declaredCommand, variables).trim();
|
|
66
|
+
if (hasUnresolvedVerifierTemplate(command)) {
|
|
67
|
+
return { target: command, evidence: "unresolved_template", exit_code: 126, message: unresolvedVerifierTemplateMessage(command) };
|
|
68
|
+
}
|
|
69
|
+
return { command };
|
|
70
|
+
}
|
|
71
|
+
function verifierCommandEnv(variables) {
|
|
72
|
+
const env = {};
|
|
73
|
+
for (const [name, value] of Object.entries(variables ?? {})) {
|
|
74
|
+
if (/^\w+$/.test(name) && SAFE_COMMAND_VARIABLE_RE.test(value)) {
|
|
75
|
+
env[name] = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return env;
|
|
79
|
+
}
|
|
80
|
+
export function hasUnresolvedVerifierTemplate(value) {
|
|
81
|
+
return /\{\{\w+\}\}/.test(value) || /(^|[^\\])\$ARGUMENTS/.test(value);
|
|
82
|
+
}
|
|
83
|
+
export function unresolvedVerifierTemplateMessage(value) {
|
|
84
|
+
return `Verifier template could not be fully resolved: ${value}`;
|
|
85
|
+
}
|
|
86
|
+
export function hasUnsafeShellControl(command) {
|
|
87
|
+
let inSingle = false;
|
|
88
|
+
let inDouble = false;
|
|
89
|
+
let escaped = false;
|
|
90
|
+
for (let i = 0; i < command.length; i++) {
|
|
91
|
+
const ch = command[i];
|
|
92
|
+
const next = command[i + 1] ?? "";
|
|
93
|
+
if (escaped) {
|
|
94
|
+
escaped = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === "\\" && !inSingle) {
|
|
98
|
+
escaped = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === "'" && !inDouble) {
|
|
102
|
+
inSingle = !inSingle;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === '"' && !inSingle) {
|
|
106
|
+
inDouble = !inDouble;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!inSingle && !inDouble) {
|
|
110
|
+
if (ch === ";" || ch === "`" || ch === "\n" || ch === "\r")
|
|
111
|
+
return true;
|
|
112
|
+
if ((ch === "&" || ch === "|" || ch === "<" || ch === ">") && next === ch)
|
|
113
|
+
return true;
|
|
114
|
+
if (ch === "$" && next === "(")
|
|
115
|
+
return true;
|
|
116
|
+
if ((ch === "<" || ch === ">") && next === "(")
|
|
117
|
+
return true;
|
|
118
|
+
if (ch === "|" || ch === "&" || ch === "<" || ch === ">")
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (inDouble) {
|
|
122
|
+
if (ch === "`")
|
|
123
|
+
return true;
|
|
124
|
+
if (ch === "$" && next === "(")
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
export function isVerifierCommandAllowed(policy, command) {
|
|
131
|
+
const normalized = command.trim();
|
|
132
|
+
if (!policy)
|
|
133
|
+
return false;
|
|
134
|
+
if (policy.allow_commands?.includes(normalized))
|
|
135
|
+
return true;
|
|
136
|
+
if (hasUnsafeShellControl(normalized))
|
|
137
|
+
return false;
|
|
138
|
+
return policy.allow_command_prefixes?.some((prefix) => normalized.startsWith(prefix)) ?? false;
|
|
139
|
+
}
|
|
140
|
+
export function verifierCommandPolicyMessage(command) {
|
|
141
|
+
return `Verifier command not allowed by verifier_policy: ${command}`;
|
|
142
|
+
}
|
|
143
|
+
export function isBuiltinAssertAllowed(policy, assertName) {
|
|
144
|
+
return policy?.allow_builtin_asserts?.includes(assertName) ?? false;
|
|
145
|
+
}
|
|
146
|
+
export function verifierAssertPolicyMessage(assertName) {
|
|
147
|
+
return `Verifier assert not allowed by verifier_policy: ${assertName}`;
|
|
148
|
+
}
|
|
149
|
+
export function trimVerifierEvidence(raw) {
|
|
150
|
+
if (!raw)
|
|
151
|
+
return undefined;
|
|
152
|
+
const trimmed = raw.trim();
|
|
153
|
+
if (!trimmed)
|
|
154
|
+
return undefined;
|
|
155
|
+
return trimmed.length > MAX_VERIFIER_EVIDENCE_BYTES
|
|
156
|
+
? trimmed.slice(0, MAX_VERIFIER_EVIDENCE_BYTES)
|
|
157
|
+
: trimmed;
|
|
158
|
+
}
|
|
159
|
+
export function summarizeCommandEvidence(stdout, stderr) {
|
|
160
|
+
const stdoutBytes = Buffer.byteLength(stdout, "utf8");
|
|
161
|
+
const stderrBytes = Buffer.byteLength(stderr, "utf8");
|
|
162
|
+
if (stdoutBytes === 0 && stderrBytes === 0)
|
|
163
|
+
return undefined;
|
|
164
|
+
return trimVerifierEvidence(`stdout_bytes=${stdoutBytes} stderr_bytes=${stderrBytes}`);
|
|
165
|
+
}
|
|
166
|
+
export async function execVerifierCommand(projectDir, command, timeoutMs = DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS, env) {
|
|
167
|
+
return new Promise((resolvePromise) => {
|
|
168
|
+
const child = spawn("bash", ["-lc", command], {
|
|
169
|
+
cwd: projectDir,
|
|
170
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
171
|
+
env: { ...process.env, ...(env ?? {}) },
|
|
172
|
+
});
|
|
173
|
+
let stdout = "";
|
|
174
|
+
let stderr = "";
|
|
175
|
+
let settled = false;
|
|
176
|
+
const settle = (result) => {
|
|
177
|
+
if (settled)
|
|
178
|
+
return;
|
|
179
|
+
settled = true;
|
|
180
|
+
clearTimeout(timeoutId);
|
|
181
|
+
resolvePromise(result);
|
|
182
|
+
};
|
|
183
|
+
const timeoutId = setTimeout(() => {
|
|
184
|
+
child.kill("SIGTERM");
|
|
185
|
+
setTimeout(() => child.kill("SIGKILL"), 1000).unref();
|
|
186
|
+
settle({ passed: false, timedOut: true, exitCode: 124, evidence: summarizeCommandEvidence(stdout, stderr) });
|
|
187
|
+
}, timeoutMs);
|
|
188
|
+
child.stdout.on("data", (chunk) => {
|
|
189
|
+
stdout += String(chunk);
|
|
190
|
+
if (stdout.length > MAX_VERIFIER_EVIDENCE_BYTES)
|
|
191
|
+
stdout = stdout.slice(0, MAX_VERIFIER_EVIDENCE_BYTES);
|
|
192
|
+
});
|
|
193
|
+
child.stderr.on("data", (chunk) => {
|
|
194
|
+
stderr += String(chunk);
|
|
195
|
+
if (stderr.length > MAX_VERIFIER_EVIDENCE_BYTES)
|
|
196
|
+
stderr = stderr.slice(0, MAX_VERIFIER_EVIDENCE_BYTES);
|
|
197
|
+
});
|
|
198
|
+
child.on("error", () => settle({
|
|
199
|
+
passed: false,
|
|
200
|
+
timedOut: false,
|
|
201
|
+
exitCode: 1,
|
|
202
|
+
evidence: summarizeCommandEvidence(stdout, stderr),
|
|
203
|
+
}));
|
|
204
|
+
child.on("close", (code) => settle({
|
|
205
|
+
passed: code === 0,
|
|
206
|
+
timedOut: false,
|
|
207
|
+
exitCode: code ?? 1,
|
|
208
|
+
evidence: summarizeCommandEvidence(stdout, stderr),
|
|
209
|
+
}));
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async function execVerifierStdout(projectDir, command, args) {
|
|
213
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
214
|
+
const child = spawn(command, args, { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"] });
|
|
215
|
+
let stdout = "";
|
|
216
|
+
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
217
|
+
child.on("error", rejectPromise);
|
|
218
|
+
child.on("close", (code) => resolvePromise({ stdout, exitCode: code ?? 1 }));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function isWithinProjectRoot(projectRoot, targetPath) {
|
|
222
|
+
const relPath = relative(projectRoot, targetPath);
|
|
223
|
+
return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath));
|
|
224
|
+
}
|
|
225
|
+
export async function resolveVerifierTarget(projectDir, targetPath) {
|
|
226
|
+
const projectRoot = await realpath(projectDir).catch(() => resolve(projectDir));
|
|
227
|
+
const resolvedPath = resolve(projectRoot, targetPath);
|
|
228
|
+
if (!isWithinProjectRoot(projectRoot, resolvedPath))
|
|
229
|
+
return null;
|
|
230
|
+
const canonicalParent = await realpath(dirname(resolvedPath)).catch(() => null);
|
|
231
|
+
if (canonicalParent && !isWithinProjectRoot(projectRoot, canonicalParent))
|
|
232
|
+
return null;
|
|
233
|
+
const canonicalTarget = await realpath(resolvedPath).catch(() => null);
|
|
234
|
+
if (canonicalTarget && !isWithinProjectRoot(projectRoot, canonicalTarget))
|
|
235
|
+
return null;
|
|
236
|
+
return canonicalTarget ?? resolvedPath;
|
|
237
|
+
}
|
|
238
|
+
export async function toProjectRelativePath(projectDir, filePath) {
|
|
239
|
+
const projectRoot = await realpath(projectDir).catch(() => resolve(projectDir));
|
|
240
|
+
const resolvedPath = resolve(projectRoot, filePath);
|
|
241
|
+
const canonicalPath = await realpath(resolvedPath).catch(() => resolvedPath);
|
|
242
|
+
const targetPath = isWithinProjectRoot(projectRoot, canonicalPath) ? canonicalPath : resolvedPath;
|
|
243
|
+
const relPath = relative(projectRoot, targetPath);
|
|
244
|
+
return relPath === "" ? "." : relPath;
|
|
245
|
+
}
|
|
246
|
+
export function getCompletionCheckFields(completion) {
|
|
247
|
+
if (typeof completion !== "object" || completion === null || Array.isArray(completion))
|
|
248
|
+
return [];
|
|
249
|
+
const fields = [];
|
|
250
|
+
if (completion.file_exists !== undefined)
|
|
251
|
+
fields.push("file_exists");
|
|
252
|
+
if (completion.file_not_empty !== undefined)
|
|
253
|
+
fields.push("file_not_empty");
|
|
254
|
+
if (completion.file_contains !== undefined)
|
|
255
|
+
fields.push("file_contains");
|
|
256
|
+
if (completion.command_success !== undefined)
|
|
257
|
+
fields.push("command_success");
|
|
258
|
+
return fields;
|
|
259
|
+
}
|
|
260
|
+
export function validateCompletionVerifier(completion) {
|
|
261
|
+
const fields = getCompletionCheckFields(completion);
|
|
262
|
+
if (fields.length !== 1) {
|
|
263
|
+
return {
|
|
264
|
+
message: "Completion verifier must define exactly one check",
|
|
265
|
+
evidence: `fields:${fields.join(",") || "none"}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const field = fields[0];
|
|
269
|
+
if (field === "file_exists" && (typeof completion.file_exists !== "string" || !completion.file_exists)) {
|
|
270
|
+
return { message: "Completion verifier file_exists must be a non-empty string", evidence: "invalid:file_exists" };
|
|
271
|
+
}
|
|
272
|
+
if (field === "file_not_empty" && (typeof completion.file_not_empty !== "string" || !completion.file_not_empty)) {
|
|
273
|
+
return { message: "Completion verifier file_not_empty must be a non-empty string", evidence: "invalid:file_not_empty" };
|
|
274
|
+
}
|
|
275
|
+
if (field === "command_success" && (typeof completion.command_success !== "string" || !completion.command_success)) {
|
|
276
|
+
return { message: "Completion verifier command_success must be a non-empty string", evidence: "invalid:command_success" };
|
|
277
|
+
}
|
|
278
|
+
if (field === "file_contains") {
|
|
279
|
+
const fileContains = completion.file_contains;
|
|
280
|
+
if (typeof fileContains !== "object" || fileContains === null) {
|
|
281
|
+
return { message: "Completion verifier file_contains must define path and pattern", evidence: "invalid:file_contains" };
|
|
282
|
+
}
|
|
283
|
+
if (typeof fileContains.path !== "string" || !fileContains.path) {
|
|
284
|
+
return { message: "Completion verifier file_contains.path must be a non-empty string", evidence: "invalid:file_contains.path" };
|
|
285
|
+
}
|
|
286
|
+
if (typeof fileContains.pattern !== "string" || !fileContains.pattern) {
|
|
287
|
+
return { message: "Completion verifier file_contains.pattern must be a non-empty string", evidence: "invalid:file_contains.pattern" };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
export async function runCompletionVerifier(completion, ir, context) {
|
|
293
|
+
const invalidCompletion = validateCompletionVerifier(completion);
|
|
294
|
+
if (invalidCompletion) {
|
|
295
|
+
return { passed: false, evidence: invalidCompletion.evidence, message: invalidCompletion.message };
|
|
296
|
+
}
|
|
297
|
+
if (completion.file_exists) {
|
|
298
|
+
const safeVariables = validateVerifierTemplateVariables(completion.file_exists, context.variables, "path");
|
|
299
|
+
if (!safeVariables.valid)
|
|
300
|
+
return { passed: false, target: completion.file_exists, evidence: safeVariables.evidence, message: safeVariables.message };
|
|
301
|
+
const target = resolveVerifierTemplate(completion.file_exists, context.variables);
|
|
302
|
+
if (hasUnresolvedVerifierTemplate(target))
|
|
303
|
+
return { passed: false, target, evidence: "unresolved_template", message: unresolvedVerifierTemplateMessage(target) };
|
|
304
|
+
const resolvedTarget = await resolveVerifierTarget(context.projectDir, target);
|
|
305
|
+
const artifact = resolvedTarget ?? undefined;
|
|
306
|
+
const evidence = resolvedTarget ? `exists:${resolvedTarget}` : "path_escape";
|
|
307
|
+
if (!resolvedTarget)
|
|
308
|
+
return { passed: false, target, evidence, artifact, message: `Verifier target escapes project root: ${target}` };
|
|
309
|
+
try {
|
|
310
|
+
await stat(resolvedTarget);
|
|
311
|
+
return { passed: true, target, evidence, artifact };
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return { passed: false, target, evidence, artifact, message: `Required file missing: ${target}` };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (completion.file_not_empty) {
|
|
318
|
+
const safeVariables = validateVerifierTemplateVariables(completion.file_not_empty, context.variables, "path");
|
|
319
|
+
if (!safeVariables.valid)
|
|
320
|
+
return { passed: false, target: completion.file_not_empty, evidence: safeVariables.evidence, message: safeVariables.message };
|
|
321
|
+
const target = resolveVerifierTemplate(completion.file_not_empty, context.variables);
|
|
322
|
+
if (hasUnresolvedVerifierTemplate(target))
|
|
323
|
+
return { passed: false, target, evidence: "unresolved_template", message: unresolvedVerifierTemplateMessage(target) };
|
|
324
|
+
const resolvedTarget = await resolveVerifierTarget(context.projectDir, target);
|
|
325
|
+
const artifact = resolvedTarget ?? undefined;
|
|
326
|
+
if (!resolvedTarget)
|
|
327
|
+
return { passed: false, target, evidence: "path_escape", artifact, message: `Verifier target escapes project root: ${target}` };
|
|
328
|
+
try {
|
|
329
|
+
const raw = await readFile(resolvedTarget, "utf-8");
|
|
330
|
+
const evidence = `bytes=${Buffer.byteLength(raw, "utf8")}`;
|
|
331
|
+
return raw.trim().length === 0
|
|
332
|
+
? { passed: false, target, evidence, artifact, message: `Required file is empty: ${target}` }
|
|
333
|
+
: { passed: true, target, evidence, artifact };
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return { passed: false, target, evidence: `not_empty:${resolvedTarget}`, artifact, message: `Required file missing: ${target}` };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (completion.file_contains) {
|
|
340
|
+
const safeTargetVariables = validateVerifierTemplateVariables(completion.file_contains.path, context.variables, "path");
|
|
341
|
+
if (!safeTargetVariables.valid)
|
|
342
|
+
return { passed: false, target: completion.file_contains.path, evidence: safeTargetVariables.evidence, message: safeTargetVariables.message };
|
|
343
|
+
const safePatternVariables = validateVerifierTemplateVariables(completion.file_contains.pattern, context.variables);
|
|
344
|
+
if (!safePatternVariables.valid)
|
|
345
|
+
return { passed: false, target: completion.file_contains.pattern, evidence: safePatternVariables.evidence, message: safePatternVariables.message };
|
|
346
|
+
const target = resolveVerifierTemplate(completion.file_contains.path, context.variables);
|
|
347
|
+
const pattern = resolveVerifierTemplate(completion.file_contains.pattern, context.variables);
|
|
348
|
+
if (hasUnresolvedVerifierTemplate(target) || hasUnresolvedVerifierTemplate(pattern)) {
|
|
349
|
+
return { passed: false, target, evidence: "unresolved_template", message: unresolvedVerifierTemplateMessage(hasUnresolvedVerifierTemplate(target) ? target : pattern) };
|
|
350
|
+
}
|
|
351
|
+
const resolvedTarget = await resolveVerifierTarget(context.projectDir, target);
|
|
352
|
+
const artifact = resolvedTarget ?? undefined;
|
|
353
|
+
const evidence = `pattern:${pattern}`;
|
|
354
|
+
if (!resolvedTarget)
|
|
355
|
+
return { passed: false, target, evidence: "path_escape", artifact, message: `Verifier target escapes project root: ${target}` };
|
|
356
|
+
try {
|
|
357
|
+
const raw = await readFile(resolvedTarget, "utf-8");
|
|
358
|
+
const re = new RegExp(pattern);
|
|
359
|
+
return re.test(raw)
|
|
360
|
+
? { passed: true, target, evidence, artifact }
|
|
361
|
+
: { passed: false, target, evidence, artifact, message: `Required pattern missing in ${target}` };
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return { passed: false, target, evidence, artifact, message: `Required file missing: ${target}` };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const rawCommand = completion.command_success;
|
|
368
|
+
const commandResolution = resolveVerifierCommand(rawCommand, ir.verifier_policy, context.variables);
|
|
369
|
+
if (!commandResolution.command)
|
|
370
|
+
return { passed: false, target: commandResolution.target, evidence: commandResolution.evidence, exit_code: commandResolution.exit_code, message: commandResolution.message };
|
|
371
|
+
const command = commandResolution.command;
|
|
372
|
+
const commandResult = await execVerifierCommand(context.projectDir, command, context.commandTimeoutMs, verifierCommandEnv(context.variables));
|
|
373
|
+
return {
|
|
374
|
+
passed: commandResult.passed,
|
|
375
|
+
target: command,
|
|
376
|
+
evidence: commandResult.evidence,
|
|
377
|
+
exit_code: commandResult.exitCode,
|
|
378
|
+
timedOut: commandResult.timedOut,
|
|
379
|
+
message: commandResult.passed
|
|
380
|
+
? undefined
|
|
381
|
+
: commandResult.timedOut
|
|
382
|
+
? `Verifier command timed out after ${context.commandTimeoutMs ?? DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS}ms: ${command}`
|
|
383
|
+
: `Verifier command failed: ${command}`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
export async function runCheckpointVerifier(checkpoint, ir, context) {
|
|
387
|
+
const assertCommands = {
|
|
388
|
+
clean_working_tree: "git status --porcelain",
|
|
389
|
+
lint_passing: "npm run lint --silent 2>/dev/null",
|
|
390
|
+
build_passing: "npm run build --silent 2>/dev/null",
|
|
391
|
+
};
|
|
392
|
+
if (checkpoint.command) {
|
|
393
|
+
const rawCommand = checkpoint.command;
|
|
394
|
+
const commandResolution = resolveVerifierCommand(rawCommand, ir.verifier_policy, context.variables);
|
|
395
|
+
if (!commandResolution.command)
|
|
396
|
+
return { passed: false, target: commandResolution.target, evidence: commandResolution.evidence, exit_code: commandResolution.exit_code, message: commandResolution.message };
|
|
397
|
+
const command = commandResolution.command;
|
|
398
|
+
const commandResult = await execVerifierCommand(context.projectDir, command, context.commandTimeoutMs, verifierCommandEnv(context.variables));
|
|
399
|
+
return {
|
|
400
|
+
passed: commandResult.passed,
|
|
401
|
+
target: command,
|
|
402
|
+
evidence: commandResult.evidence,
|
|
403
|
+
exit_code: commandResult.exitCode,
|
|
404
|
+
timedOut: commandResult.timedOut,
|
|
405
|
+
message: commandResult.passed
|
|
406
|
+
? checkpoint.message
|
|
407
|
+
: commandResult.timedOut
|
|
408
|
+
? `Verifier command timed out after ${context.commandTimeoutMs ?? DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS}ms: ${command}`
|
|
409
|
+
: `Verifier command failed: ${command}`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (checkpoint.assert === "clean_working_tree") {
|
|
413
|
+
if (!isBuiltinAssertAllowed(ir.verifier_policy, checkpoint.assert)) {
|
|
414
|
+
return { passed: false, target: checkpoint.assert, evidence: "policy_denied", exit_code: 126, message: verifierAssertPolicyMessage(checkpoint.assert) };
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const { stdout, exitCode } = await execVerifierStdout(context.projectDir, "git", ["status", "--porcelain"]);
|
|
418
|
+
const changedEntries = stdout.split("\n").filter(Boolean).length;
|
|
419
|
+
return {
|
|
420
|
+
passed: exitCode === 0 && stdout.trim() === "",
|
|
421
|
+
target: checkpoint.assert,
|
|
422
|
+
evidence: `changed_entries=${changedEntries}`,
|
|
423
|
+
exit_code: exitCode,
|
|
424
|
+
message: checkpoint.message,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return { passed: false, target: checkpoint.assert, exit_code: 1, message: checkpoint.message };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (checkpoint.assert === "no_test_regression") {
|
|
432
|
+
if (!isBuiltinAssertAllowed(ir.verifier_policy, checkpoint.assert)) {
|
|
433
|
+
return { passed: false, target: checkpoint.assert, evidence: "policy_denied", exit_code: 126, message: verifierAssertPolicyMessage(checkpoint.assert) };
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const baseline = parseInt(await readFile(resolve(context.projectDir, ".dna/test-baseline"), "utf-8").catch(() => "0"), 10);
|
|
437
|
+
const current = parseInt(await readFile(resolve(context.projectDir, ".dna/test-current"), "utf-8").catch(() => "0"), 10);
|
|
438
|
+
return { passed: current <= baseline, target: checkpoint.assert, evidence: `baseline=${baseline} current=${current}`, exit_code: 0, message: checkpoint.message };
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return { passed: false, target: checkpoint.assert, exit_code: 1, message: checkpoint.message };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (assertCommands[checkpoint.assert]) {
|
|
445
|
+
if (!isBuiltinAssertAllowed(ir.verifier_policy, checkpoint.assert)) {
|
|
446
|
+
return { passed: false, target: checkpoint.assert, evidence: "policy_denied", exit_code: 126, message: verifierAssertPolicyMessage(checkpoint.assert) };
|
|
447
|
+
}
|
|
448
|
+
const commandResult = await execVerifierCommand(context.projectDir, assertCommands[checkpoint.assert], context.commandTimeoutMs);
|
|
449
|
+
return {
|
|
450
|
+
passed: commandResult.passed,
|
|
451
|
+
target: checkpoint.assert,
|
|
452
|
+
evidence: commandResult.evidence,
|
|
453
|
+
exit_code: commandResult.exitCode,
|
|
454
|
+
message: commandResult.passed
|
|
455
|
+
? checkpoint.message
|
|
456
|
+
: commandResult.timedOut
|
|
457
|
+
? `Verifier command timed out after ${context.commandTimeoutMs ?? DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS}ms: ${assertCommands[checkpoint.assert]}`
|
|
458
|
+
: checkpoint.message,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return { passed: false, target: checkpoint.assert, exit_code: 1, message: checkpoint.message };
|
|
462
|
+
}
|