intentdna 1.4.6 → 1.4.8
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/commands/feedback.d.ts +3 -1
- package/dist/cli/commands/feedback.js +37 -3
- package/dist/cli/index.js +3 -1
- package/dist/evolution/trace-bridge.d.ts +31 -0
- package/dist/evolution/trace-bridge.js +99 -0
- package/dist/hooks/cli.js +37 -10
- package/dist/hooks/enforce.d.ts +15 -2
- package/dist/hooks/enforce.js +150 -16
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dna feedback [--days <N>] [--json]
|
|
2
|
+
* dna feedback [--days <N>] [--json] [--evolve]
|
|
3
3
|
*
|
|
4
4
|
* Analyze trace/audit data and produce actionable feedback for template optimization.
|
|
5
|
+
* With --evolve: convert trace data into epigenetic markers (preview by default).
|
|
5
6
|
*
|
|
6
7
|
* Reads: .dna/state/trace/trace-*.jsonl
|
|
7
8
|
* Outputs: human-readable report (or JSON with --json)
|
|
@@ -9,6 +10,7 @@
|
|
|
9
10
|
export interface FeedbackOptions {
|
|
10
11
|
days: number;
|
|
11
12
|
json: boolean;
|
|
13
|
+
evolve: boolean;
|
|
12
14
|
}
|
|
13
15
|
export interface FeedbackReport {
|
|
14
16
|
period_days: number;
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dna feedback [--days <N>] [--json]
|
|
2
|
+
* dna feedback [--days <N>] [--json] [--evolve]
|
|
3
3
|
*
|
|
4
4
|
* Analyze trace/audit data and produce actionable feedback for template optimization.
|
|
5
|
+
* With --evolve: convert trace data into epigenetic markers (preview by default).
|
|
5
6
|
*
|
|
6
7
|
* Reads: .dna/state/trace/trace-*.jsonl
|
|
7
8
|
* Outputs: human-readable report (or JSON with --json)
|
|
8
9
|
*/
|
|
9
10
|
import { readTraces } from "../../hooks/state.js";
|
|
11
|
+
import { traceToOutcomes, summarizeConversion } from "../../evolution/trace-bridge.js";
|
|
12
|
+
import { describeMarkerChanges } from "../../evolution/marker-gen.js";
|
|
13
|
+
import { EvolutionStore } from "../../evolution/store.js";
|
|
10
14
|
function aggregate(traces) {
|
|
11
15
|
let blocks = 0, warns = 0, allows = 0;
|
|
12
16
|
const toolCounts = new Map();
|
|
@@ -130,11 +134,41 @@ export async function runFeedback(opts) {
|
|
|
130
134
|
const stats = aggregate(traces);
|
|
131
135
|
const suggestions = generateSuggestions(stats);
|
|
132
136
|
const report = { ...stats, period_days: opts.days, suggestions };
|
|
133
|
-
if (opts.json) {
|
|
137
|
+
if (opts.json && !opts.evolve) {
|
|
134
138
|
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
135
139
|
}
|
|
136
|
-
else {
|
|
140
|
+
else if (!opts.evolve) {
|
|
137
141
|
process.stderr.write(formatReport(report));
|
|
138
142
|
}
|
|
143
|
+
// --evolve: convert traces → epigenetic markers
|
|
144
|
+
if (opts.evolve) {
|
|
145
|
+
const outcomes = traceToOutcomes(traces);
|
|
146
|
+
if (outcomes.length === 0) {
|
|
147
|
+
process.stderr.write(formatReport(report));
|
|
148
|
+
process.stderr.write("\nNo actionable gene feedback found in traces — nothing to evolve.\n");
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const store = new EvolutionStore();
|
|
152
|
+
const existingMarkers = await store.loadMarkers("default");
|
|
153
|
+
const changes = describeMarkerChanges(outcomes, existingMarkers);
|
|
154
|
+
process.stderr.write(formatReport(report));
|
|
155
|
+
process.stderr.write("\n" + summarizeConversion(outcomes) + "\n\n");
|
|
156
|
+
process.stderr.write("Marker changes (preview):\n");
|
|
157
|
+
for (const c of changes) {
|
|
158
|
+
if (c.type === "create") {
|
|
159
|
+
process.stderr.write(` + CREATE ${c.gene} → ${c.action} x${c.factor.toFixed(2)} (${c.feedback_count} feedbacks)\n`);
|
|
160
|
+
}
|
|
161
|
+
else if (c.type === "update") {
|
|
162
|
+
process.stderr.write(` ~ UPDATE ${c.gene} → ${c.action} x${c.factor.toFixed(2)} (${c.feedback_count} feedbacks)\n`);
|
|
163
|
+
}
|
|
164
|
+
else if (c.type === "neutral") {
|
|
165
|
+
process.stderr.write(` = NEUTRAL ${c.gene} x${c.factor.toFixed(2)} (${c.feedback_count} feedbacks)\n`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
process.stderr.write(` ? INSUFFICIENT ${c.gene} (${c.feedback_count}/${c.needed} needed)\n`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
process.stderr.write("\nRun `dna evolve --apply` to persist markers.\n");
|
|
172
|
+
}
|
|
139
173
|
return 0;
|
|
140
174
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -29,7 +29,7 @@ Commands:
|
|
|
29
29
|
show Show gene expression state
|
|
30
30
|
evolve Generate epigenetic markers from outcomes
|
|
31
31
|
epigenetic Record outcomes, view markers and summaries
|
|
32
|
-
feedback Analyze trace data, suggest template optimizations (--days <N>, --json)
|
|
32
|
+
feedback Analyze trace data, suggest template optimizations (--days <N>, --json, --evolve)
|
|
33
33
|
|
|
34
34
|
Options:
|
|
35
35
|
--help, -h Show help for a command
|
|
@@ -412,6 +412,7 @@ async function main() {
|
|
|
412
412
|
options: {
|
|
413
413
|
days: { type: "string", short: "d", default: "7" },
|
|
414
414
|
json: { type: "boolean", default: false },
|
|
415
|
+
evolve: { type: "boolean", default: false },
|
|
415
416
|
},
|
|
416
417
|
strict: false,
|
|
417
418
|
});
|
|
@@ -419,6 +420,7 @@ async function main() {
|
|
|
419
420
|
const code = await runFeedback({
|
|
420
421
|
days: parseInt(fbValues.days, 10) || 7,
|
|
421
422
|
json: fbValues.json,
|
|
423
|
+
evolve: fbValues.evolve,
|
|
422
424
|
});
|
|
423
425
|
process.exit(code);
|
|
424
426
|
break;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent DNA — Trace-to-Evolution Bridge
|
|
3
|
+
*
|
|
4
|
+
* Converts hook trace data into ExecutionOutcomes that the epigenetic
|
|
5
|
+
* marker generator can process. This closes the feedback loop:
|
|
6
|
+
* traces (observed behavior) → outcomes → markers → gene modulation
|
|
7
|
+
*
|
|
8
|
+
* Gene extraction from trace reasons:
|
|
9
|
+
* - Tool filters: "(gene: <name>)" in reason
|
|
10
|
+
* - Role scope: "Role '<name>'" → gene "scope:<role>"
|
|
11
|
+
* - Gates: "(gene: <name>)" pattern or fallback to "gate"
|
|
12
|
+
*/
|
|
13
|
+
import type { TraceEntry } from "../hooks/state.js";
|
|
14
|
+
import type { ExecutionOutcome } from "./types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Extract the gene name from a trace entry's reason string.
|
|
17
|
+
* Returns null if no gene can be determined.
|
|
18
|
+
*/
|
|
19
|
+
export declare function extractGeneFromReason(reason: string | undefined): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Convert trace entries into ExecutionOutcomes for the evolution engine.
|
|
22
|
+
*
|
|
23
|
+
* Groups block/warn traces by gene, creates one outcome per unique
|
|
24
|
+
* (gene, decision) pair. Allow decisions are not converted since
|
|
25
|
+
* they don't carry gene information.
|
|
26
|
+
*/
|
|
27
|
+
export declare function traceToOutcomes(traces: TraceEntry[], dnaId?: string): ExecutionOutcome[];
|
|
28
|
+
/**
|
|
29
|
+
* Summarize trace-to-outcome conversion for human review.
|
|
30
|
+
*/
|
|
31
|
+
export declare function summarizeConversion(outcomes: ExecutionOutcome[]): string;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent DNA — Trace-to-Evolution Bridge
|
|
3
|
+
*
|
|
4
|
+
* Converts hook trace data into ExecutionOutcomes that the epigenetic
|
|
5
|
+
* marker generator can process. This closes the feedback loop:
|
|
6
|
+
* traces (observed behavior) → outcomes → markers → gene modulation
|
|
7
|
+
*
|
|
8
|
+
* Gene extraction from trace reasons:
|
|
9
|
+
* - Tool filters: "(gene: <name>)" in reason
|
|
10
|
+
* - Role scope: "Role '<name>'" → gene "scope:<role>"
|
|
11
|
+
* - Gates: "(gene: <name>)" pattern or fallback to "gate"
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
/**
|
|
15
|
+
* Extract the gene name from a trace entry's reason string.
|
|
16
|
+
* Returns null if no gene can be determined.
|
|
17
|
+
*/
|
|
18
|
+
export function extractGeneFromReason(reason) {
|
|
19
|
+
if (!reason)
|
|
20
|
+
return null;
|
|
21
|
+
// Pattern: (gene: <name>) — from tool filters and gates
|
|
22
|
+
const geneMatch = reason.match(/\(gene:\s*(\w+)\)/);
|
|
23
|
+
if (geneMatch)
|
|
24
|
+
return geneMatch[1];
|
|
25
|
+
// Pattern: Role '<name>' — from scope enforcement
|
|
26
|
+
const roleMatch = reason.match(/Role '(\w+)'/);
|
|
27
|
+
if (roleMatch)
|
|
28
|
+
return `scope:${roleMatch[1]}`;
|
|
29
|
+
// Pattern: source_gene in gate messages
|
|
30
|
+
const sourceMatch = reason.match(/source_gene:\s*(\w+)/);
|
|
31
|
+
if (sourceMatch)
|
|
32
|
+
return sourceMatch[1];
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Convert trace entries into ExecutionOutcomes for the evolution engine.
|
|
37
|
+
*
|
|
38
|
+
* Groups block/warn traces by gene, creates one outcome per unique
|
|
39
|
+
* (gene, decision) pair. Allow decisions are not converted since
|
|
40
|
+
* they don't carry gene information.
|
|
41
|
+
*/
|
|
42
|
+
export function traceToOutcomes(traces, dnaId = "default") {
|
|
43
|
+
// Collect feedback per gene from block/warn traces
|
|
44
|
+
const geneFeedback = new Map();
|
|
45
|
+
for (const trace of traces) {
|
|
46
|
+
if (trace.decision === "allow")
|
|
47
|
+
continue;
|
|
48
|
+
const gene = extractGeneFromReason(trace.reason);
|
|
49
|
+
if (!gene)
|
|
50
|
+
continue;
|
|
51
|
+
const existing = geneFeedback.get(gene) ?? { hindered: 0, details: [] };
|
|
52
|
+
existing.hindered++;
|
|
53
|
+
if (trace.reason && existing.details.length < 3) {
|
|
54
|
+
const short = trace.reason.split("\n")[0].slice(0, 80);
|
|
55
|
+
existing.details.push(short);
|
|
56
|
+
}
|
|
57
|
+
geneFeedback.set(gene, existing);
|
|
58
|
+
}
|
|
59
|
+
// Convert to ExecutionOutcomes
|
|
60
|
+
const outcomes = [];
|
|
61
|
+
const totalEvents = traces.length;
|
|
62
|
+
const timestamp = new Date().toISOString();
|
|
63
|
+
for (const [gene, stats] of geneFeedback) {
|
|
64
|
+
const feedback = [];
|
|
65
|
+
// Each block counts as a "hindered" feedback
|
|
66
|
+
for (let i = 0; i < stats.hindered; i++) {
|
|
67
|
+
feedback.push({
|
|
68
|
+
gene,
|
|
69
|
+
effect: "hindered",
|
|
70
|
+
detail: stats.details[i] ?? `blocked ${stats.hindered} times`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
outcomes.push({
|
|
74
|
+
id: `trace_${randomUUID().slice(0, 8)}`,
|
|
75
|
+
timestamp,
|
|
76
|
+
dna_id: dnaId,
|
|
77
|
+
action: `enforcement: ${gene} blocked ${stats.hindered}/${totalEvents} events`,
|
|
78
|
+
active_genes: [gene],
|
|
79
|
+
constraints_applied: [gene],
|
|
80
|
+
success: false, // Blocks indicate friction
|
|
81
|
+
constraint_feedback: feedback,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return outcomes;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Summarize trace-to-outcome conversion for human review.
|
|
88
|
+
*/
|
|
89
|
+
export function summarizeConversion(outcomes) {
|
|
90
|
+
if (outcomes.length === 0)
|
|
91
|
+
return "No actionable gene feedback found in traces.";
|
|
92
|
+
const lines = ["Trace → Epigenetic conversion:"];
|
|
93
|
+
for (const o of outcomes) {
|
|
94
|
+
const gene = o.active_genes[0];
|
|
95
|
+
const count = o.constraint_feedback.length;
|
|
96
|
+
lines.push(` ${gene}: ${count} block${count > 1 ? "s" : ""} → will suppress`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
package/dist/hooks/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
18
18
|
import { resolve } from "node:path";
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
20
|
import { readStdin, writeOutput, silentOutput, allowOutput } from "./protocol.js";
|
|
21
|
-
import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, } from "./enforce.js";
|
|
21
|
+
import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, } from "./enforce.js";
|
|
22
22
|
import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces } from "./state.js";
|
|
23
23
|
// ── Constants ──────────────────────────────────────────────
|
|
24
24
|
const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
|
|
@@ -92,18 +92,21 @@ async function main() {
|
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
const state = {};
|
|
95
|
-
// Load workflow state for events that need handoff context
|
|
96
|
-
if (event === "PreToolUse") {
|
|
95
|
+
// Load workflow state for events that need it (handoff context + PreCompact preservation)
|
|
96
|
+
if (event === "PreToolUse" || event === "PreCompact") {
|
|
97
97
|
const wfState = await readWorkflowState(projectDir, sessionId);
|
|
98
98
|
if (wfState && wfState.active) {
|
|
99
99
|
state.workflowState = {
|
|
100
100
|
current_step: wfState.current_step,
|
|
101
101
|
workflow: wfState.workflow,
|
|
102
|
+
current_role: wfState.current_role,
|
|
102
103
|
completed_artifacts: wfState.completed_artifacts,
|
|
103
104
|
};
|
|
104
105
|
// Re-run fallback: scan consumed artifact paths on disk so
|
|
105
106
|
// enforceHandoffConsumes can skip blocks for files that already exist.
|
|
106
|
-
|
|
107
|
+
if (event === "PreToolUse") {
|
|
108
|
+
state.existingArtifactPaths = await scanConsumedArtifactPaths(projectDir, ir, wfState.workflow, wfState.current_step);
|
|
109
|
+
}
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
// Special handling for Stop — needs async workflow state read + session summary
|
|
@@ -154,17 +157,37 @@ async function main() {
|
|
|
154
157
|
}
|
|
155
158
|
// Dispatch to enforcement engine with timing
|
|
156
159
|
const start = Date.now();
|
|
157
|
-
|
|
160
|
+
let output = dispatch(event, ir, rawInput, state);
|
|
158
161
|
const durationMs = Date.now() - start;
|
|
162
|
+
// Extract target file path for trace + pattern detection
|
|
163
|
+
const toolInput = typeof rawInput.tool_input === "object" && rawInput.tool_input !== null
|
|
164
|
+
? rawInput.tool_input : undefined;
|
|
165
|
+
let targetPath = typeof toolInput?.file_path === "string" ? toolInput.file_path : undefined;
|
|
166
|
+
if (!targetPath && typeof toolInput?.command === "string") {
|
|
167
|
+
const bashPaths = extractBashWritePaths(toolInput.command);
|
|
168
|
+
if (bashPaths.length > 0)
|
|
169
|
+
targetPath = bashPaths[0];
|
|
170
|
+
}
|
|
171
|
+
// O4: Block pattern recognition — suggest scope expansion on repeated blocks
|
|
172
|
+
if (event === "PreToolUse" && output.continue === false && targetPath) {
|
|
173
|
+
try {
|
|
174
|
+
const traces = await readTraces(projectDir, 1);
|
|
175
|
+
const blockCount = traces.filter(t => t.decision === "block" && t.target_path === targetPath).length;
|
|
176
|
+
if (blockCount >= 2) {
|
|
177
|
+
output = {
|
|
178
|
+
...output,
|
|
179
|
+
reason: (output.reason ?? "") +
|
|
180
|
+
`\n\n[Intent DNA] Pattern detected: '${targetPath}' blocked ${blockCount + 1} times this session. Consider expanding the role's write scope.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* fail-open: pattern detection never blocks */ }
|
|
185
|
+
}
|
|
159
186
|
writeOutput(output);
|
|
160
187
|
// Trace logging (async, fail-open)
|
|
161
188
|
const decision = output.continue === false ? "block"
|
|
162
189
|
: output.hookSpecificOutput?.additionalContext?.startsWith("WARN") ? "warn"
|
|
163
190
|
: "allow";
|
|
164
|
-
// Extract target file path from tool_input (Write/Edit/Read)
|
|
165
|
-
const toolInput = typeof rawInput.tool_input === "object" && rawInput.tool_input !== null
|
|
166
|
-
? rawInput.tool_input : undefined;
|
|
167
|
-
const targetPath = typeof toolInput?.file_path === "string" ? toolInput.file_path : undefined;
|
|
168
191
|
appendTrace(projectDir, {
|
|
169
192
|
trace_id: randomUUID(),
|
|
170
193
|
event,
|
|
@@ -221,7 +244,11 @@ function dispatch(event, ir, input, state) {
|
|
|
221
244
|
agent_type: typeof input.agent_type === "string" ? input.agent_type : undefined,
|
|
222
245
|
});
|
|
223
246
|
case "PreCompact":
|
|
224
|
-
return enforcePreCompact(ir
|
|
247
|
+
return enforcePreCompact(ir, state.workflowState ? {
|
|
248
|
+
workflow: state.workflowState.workflow,
|
|
249
|
+
current_step: state.workflowState.current_step,
|
|
250
|
+
current_role: state.workflowState.current_role ?? "",
|
|
251
|
+
} : null);
|
|
225
252
|
case "Notification":
|
|
226
253
|
return enforceNotification(ir, {
|
|
227
254
|
title: typeof input.title === "string" ? input.title : undefined,
|
package/dist/hooks/enforce.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface EnforceState {
|
|
|
26
26
|
workflowState?: {
|
|
27
27
|
current_step: string;
|
|
28
28
|
workflow: string;
|
|
29
|
+
current_role?: string;
|
|
29
30
|
completed_artifacts?: CompletedArtifactEntry[];
|
|
30
31
|
};
|
|
31
32
|
/** Artifact paths that exist on disk — fallback for re-run idempotency.
|
|
@@ -41,7 +42,8 @@ export interface EnforceState {
|
|
|
41
42
|
export declare function enforcePreToolUse(ir: ConstraintIR, input: PreToolUseInput, state?: EnforceState, roles?: Record<string, RoleDef>): HookOutput;
|
|
42
43
|
/**
|
|
43
44
|
* Enforce PostToolUse validators.
|
|
44
|
-
*
|
|
45
|
+
* 1. Runs post-execution validators (audit)
|
|
46
|
+
* 2. Verifies Bash write paths were within scope (defense-in-depth)
|
|
45
47
|
*/
|
|
46
48
|
export declare function enforcePostToolUse(ir: ConstraintIR, input: PostToolUseInput): HookOutput;
|
|
47
49
|
/**
|
|
@@ -55,8 +57,13 @@ export declare function enforceUserPromptSubmit(ir: ConstraintIR, _input: UserPr
|
|
|
55
57
|
export declare function enforceSubagentStop(ir: ConstraintIR, input: SubagentStopInput): HookOutput;
|
|
56
58
|
/**
|
|
57
59
|
* Preserve DNA directive summary before context compaction.
|
|
60
|
+
* Includes: high-priority directives, role scope map, active workflow state.
|
|
58
61
|
*/
|
|
59
|
-
export declare function enforcePreCompact(ir: ConstraintIR
|
|
62
|
+
export declare function enforcePreCompact(ir: ConstraintIR, workflowContext?: {
|
|
63
|
+
workflow: string;
|
|
64
|
+
current_step: string;
|
|
65
|
+
current_role: string;
|
|
66
|
+
} | null): HookOutput;
|
|
60
67
|
/**
|
|
61
68
|
* Check if a notification is a DNA violation and return audit info.
|
|
62
69
|
* The CLI handles actual audit file writes.
|
|
@@ -124,3 +131,9 @@ export declare function evaluateGateCondition(condition: string, input: PreToolU
|
|
|
124
131
|
* Resolve a tool filter target to concrete Claude Code tool names.
|
|
125
132
|
*/
|
|
126
133
|
export declare function resolveToolTarget(target?: string): string[] | null;
|
|
134
|
+
/**
|
|
135
|
+
* Extract file paths that a Bash command writes to.
|
|
136
|
+
* Detects: output redirection (> / >>), tee, cp, mv.
|
|
137
|
+
* Returns deduplicated list of target paths.
|
|
138
|
+
*/
|
|
139
|
+
export declare function extractBashWritePaths(command: string): string[];
|
package/dist/hooks/enforce.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Step checkpoints are handled by the CLI, not here.
|
|
15
15
|
*/
|
|
16
16
|
import { allowOutput, blockOutput, escalateOutput, silentOutput } from "./protocol.js";
|
|
17
|
+
import { relative, normalize } from "node:path";
|
|
17
18
|
// ── PreToolUse Enforcement ─────────────────────────────────
|
|
18
19
|
/**
|
|
19
20
|
* Enforce PreToolUse constraints.
|
|
@@ -57,16 +58,31 @@ export function enforcePreToolUse(ir, input, state, roles) {
|
|
|
57
58
|
// ── PostToolUse Enforcement ────────────────────────────────
|
|
58
59
|
/**
|
|
59
60
|
* Enforce PostToolUse validators.
|
|
60
|
-
*
|
|
61
|
+
* 1. Runs post-execution validators (audit)
|
|
62
|
+
* 2. Verifies Bash write paths were within scope (defense-in-depth)
|
|
61
63
|
*/
|
|
62
64
|
export function enforcePostToolUse(ir, input) {
|
|
63
|
-
const validators = ir.post_execution_validators;
|
|
64
|
-
if (validators.length === 0)
|
|
65
|
-
return silentOutput();
|
|
66
65
|
const lines = [];
|
|
66
|
+
// Layer 1: Post-execution validators
|
|
67
|
+
const validators = ir.post_execution_validators;
|
|
67
68
|
for (const v of validators) {
|
|
68
69
|
lines.push(`[Intent DNA] PostToolUse validator: ${v.check} (gene: ${v.source_gene})`);
|
|
69
70
|
}
|
|
71
|
+
// Layer 2: Bash write scope verification (defense-in-depth)
|
|
72
|
+
if (input.tool_name === "Bash" && input.agent_type && ir.roles_scope_map && ir.roles_scope_map.length > 0) {
|
|
73
|
+
const cmd = input.tool_input.command;
|
|
74
|
+
if (typeof cmd === "string") {
|
|
75
|
+
const writePaths = extractBashWritePaths(cmd);
|
|
76
|
+
if (writePaths.length > 0) {
|
|
77
|
+
const violations = verifyPathsAgainstScope(ir.roles_scope_map, input.agent_type, writePaths, input.cwd);
|
|
78
|
+
for (const v of violations) {
|
|
79
|
+
lines.push(`WARN [Intent DNA] PostToolUse: Bash wrote outside scope — ${v}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (lines.length === 0)
|
|
85
|
+
return silentOutput();
|
|
70
86
|
return allowOutput(lines.join("\n"));
|
|
71
87
|
}
|
|
72
88
|
// ── UserPromptSubmit Enforcement ───────────────────────────
|
|
@@ -112,14 +128,18 @@ export function enforceSubagentStop(ir, input) {
|
|
|
112
128
|
// ── PreCompact Enforcement ─────────────────────────────────
|
|
113
129
|
/**
|
|
114
130
|
* Preserve DNA directive summary before context compaction.
|
|
131
|
+
* Includes: high-priority directives, role scope map, active workflow state.
|
|
115
132
|
*/
|
|
116
|
-
export function enforcePreCompact(ir) {
|
|
117
|
-
|
|
133
|
+
export function enforcePreCompact(ir, workflowContext) {
|
|
134
|
+
const hasDirectives = ir.prompt_directives.length > 0;
|
|
135
|
+
const hasRoles = ir.roles_scope_map && ir.roles_scope_map.length > 0;
|
|
136
|
+
const hasWorkflow = !!workflowContext;
|
|
137
|
+
if (!hasDirectives && !hasRoles && !hasWorkflow)
|
|
118
138
|
return silentOutput();
|
|
119
139
|
const critical = ir.prompt_directives.filter(d => d.priority === "high");
|
|
120
140
|
const standard = ir.prompt_directives.filter(d => d.priority === "medium");
|
|
121
141
|
const lines = [
|
|
122
|
-
"[Intent DNA] PreCompact: Preserving
|
|
142
|
+
"[Intent DNA] PreCompact: Preserving governance context for compact",
|
|
123
143
|
];
|
|
124
144
|
if (critical.length > 0) {
|
|
125
145
|
lines.push(" Critical directives:");
|
|
@@ -133,6 +153,19 @@ export function enforcePreCompact(ir) {
|
|
|
133
153
|
lines.push(` - [${d.source_gene}] ${d.text}`);
|
|
134
154
|
}
|
|
135
155
|
}
|
|
156
|
+
// Preserve role scope map so agent knows its boundaries post-compact
|
|
157
|
+
if (hasRoles) {
|
|
158
|
+
lines.push(" Active role scopes:");
|
|
159
|
+
for (const entry of ir.roles_scope_map) {
|
|
160
|
+
const writeGlobs = entry.scope.write ?? [];
|
|
161
|
+
const readGlobs = entry.scope.read ?? [];
|
|
162
|
+
lines.push(` - ${entry.role_name}: write=[${writeGlobs.join(",")}] read=[${readGlobs.join(",")}]`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Preserve workflow state so agent retains step context
|
|
166
|
+
if (hasWorkflow) {
|
|
167
|
+
lines.push(` Active workflow: ${workflowContext.workflow} (step: ${workflowContext.current_step}, role: ${workflowContext.current_role})`);
|
|
168
|
+
}
|
|
136
169
|
return allowOutput(lines.join("\n"));
|
|
137
170
|
}
|
|
138
171
|
// ── Notification Enforcement ───────────────────────────────
|
|
@@ -314,11 +347,36 @@ export function enforceHandoffProduces(ir, wfState) {
|
|
|
314
347
|
function enforceRoleScope(rolesScopeMap, input) {
|
|
315
348
|
if (!input.agent_type)
|
|
316
349
|
return null;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
350
|
+
// Standard write tools: check single file path
|
|
351
|
+
if (isWriteTool(input.tool_name)) {
|
|
352
|
+
const filePath = extractFilePath(input);
|
|
353
|
+
if (!filePath)
|
|
354
|
+
return null;
|
|
355
|
+
return checkPathAgainstScope(rolesScopeMap, input, filePath);
|
|
356
|
+
}
|
|
357
|
+
// Bash: extract potential write targets from command
|
|
358
|
+
if (input.tool_name === "Bash") {
|
|
359
|
+
const cmd = input.tool_input.command;
|
|
360
|
+
if (typeof cmd !== "string")
|
|
361
|
+
return null;
|
|
362
|
+
const writePaths = extractBashWritePaths(cmd);
|
|
363
|
+
if (writePaths.length === 0)
|
|
364
|
+
return null;
|
|
365
|
+
for (const p of writePaths) {
|
|
366
|
+
const result = checkPathAgainstScope(rolesScopeMap, input, p);
|
|
367
|
+
if (result)
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
321
370
|
return null;
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
/** Check a single file path against role scope. Shared by Write tools and Bash. */
|
|
375
|
+
function checkPathAgainstScope(rolesScopeMap, input, filePath) {
|
|
376
|
+
const cwd = input.cwd;
|
|
377
|
+
let relativePath = cwd && filePath.startsWith("/") ? relative(cwd, filePath) : filePath;
|
|
378
|
+
// Normalize to resolve traversal (e.g., "src/../../test/x.ts" → "../test/x.ts")
|
|
379
|
+
relativePath = normalize(relativePath);
|
|
322
380
|
for (const entry of rolesScopeMap) {
|
|
323
381
|
const agentTypeName = `dna-${toKebabCase(entry.role_name)}`;
|
|
324
382
|
if (input.agent_type !== agentTypeName)
|
|
@@ -327,13 +385,34 @@ function enforceRoleScope(rolesScopeMap, input) {
|
|
|
327
385
|
if (writeGlobs.length === 0) {
|
|
328
386
|
return blockOutput(`[Intent DNA]: Role '${entry.role_name}' has no write permission (path: ${filePath})`);
|
|
329
387
|
}
|
|
330
|
-
if (!checkWriteAllowed(
|
|
388
|
+
if (!checkWriteAllowed(relativePath, writeGlobs)) {
|
|
331
389
|
return blockOutput(`[Intent DNA]: Role '${entry.role_name}' cannot write to '${filePath}' (allowed: ${writeGlobs.join(", ")})`);
|
|
332
390
|
}
|
|
333
391
|
return null; // Role matched, write allowed
|
|
334
392
|
}
|
|
335
393
|
return null; // No matching role — allow
|
|
336
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Verify a list of paths against role scope. Returns violation messages.
|
|
397
|
+
* Used by PostToolUse for defense-in-depth auditing (warn, not block).
|
|
398
|
+
*/
|
|
399
|
+
function verifyPathsAgainstScope(rolesScopeMap, agentType, paths, cwd) {
|
|
400
|
+
const violations = [];
|
|
401
|
+
for (const entry of rolesScopeMap) {
|
|
402
|
+
const agentTypeName = `dna-${toKebabCase(entry.role_name)}`;
|
|
403
|
+
if (agentType !== agentTypeName)
|
|
404
|
+
continue;
|
|
405
|
+
const writeGlobs = entry.scope.write ?? [];
|
|
406
|
+
for (const p of paths) {
|
|
407
|
+
const relativePath = cwd && p.startsWith("/") ? relative(cwd, p) : p;
|
|
408
|
+
if (writeGlobs.length === 0 || !checkWriteAllowed(relativePath, writeGlobs)) {
|
|
409
|
+
violations.push(`role '${entry.role_name}' wrote to '${p}' (allowed: ${writeGlobs.join(", ") || "none"})`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
break; // Found the role, done
|
|
413
|
+
}
|
|
414
|
+
return violations;
|
|
415
|
+
}
|
|
337
416
|
function enforceToolFilters(filters, input) {
|
|
338
417
|
for (const filter of filters) {
|
|
339
418
|
const matchedTools = resolveToolTarget(filter.target);
|
|
@@ -482,12 +561,67 @@ function extractFilePath(input) {
|
|
|
482
561
|
const fp = input.tool_input.file_path;
|
|
483
562
|
if (typeof fp === "string" && fp)
|
|
484
563
|
return fp;
|
|
485
|
-
// Bash tool — try to extract from command
|
|
486
|
-
const cmd = input.tool_input.command;
|
|
487
|
-
if (typeof cmd === "string")
|
|
488
|
-
return null; // Can't reliably extract
|
|
489
564
|
return null;
|
|
490
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* Extract file paths that a Bash command writes to.
|
|
568
|
+
* Detects: output redirection (> / >>), tee, cp, mv.
|
|
569
|
+
* Returns deduplicated list of target paths.
|
|
570
|
+
*/
|
|
571
|
+
export function extractBashWritePaths(command) {
|
|
572
|
+
const paths = [];
|
|
573
|
+
// 1. Output redirection: > or >>
|
|
574
|
+
// Skip fd duplication (>&N, >&-), /dev/ paths, and variable expansions
|
|
575
|
+
const redirectRegex = />{1,2}\s*(?!&)(?:"([^"]+)"|'([^']+)'|([^\s;|&><()`$]+))/g;
|
|
576
|
+
let match;
|
|
577
|
+
while ((match = redirectRegex.exec(command)) !== null) {
|
|
578
|
+
const p = match[1] ?? match[2] ?? match[3];
|
|
579
|
+
if (p && !p.startsWith("/dev/"))
|
|
580
|
+
paths.push(p);
|
|
581
|
+
}
|
|
582
|
+
// 2. tee command: writes stdin to file(s)
|
|
583
|
+
// tee [-ai] file1 [file2 ...]
|
|
584
|
+
const teeMatch = command.match(/\btee\s+(.*?)(?:[;|&]|$)/);
|
|
585
|
+
if (teeMatch) {
|
|
586
|
+
const teeArgs = teeMatch[1].trim().split(/\s+/);
|
|
587
|
+
for (const arg of teeArgs) {
|
|
588
|
+
if (arg.startsWith("-"))
|
|
589
|
+
continue;
|
|
590
|
+
if (arg.startsWith("/dev/"))
|
|
591
|
+
continue;
|
|
592
|
+
if (arg.startsWith("$"))
|
|
593
|
+
continue;
|
|
594
|
+
// Handle quoted paths
|
|
595
|
+
const cleaned = arg.replace(/^["']|["']$/g, "");
|
|
596
|
+
if (cleaned)
|
|
597
|
+
paths.push(cleaned);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// 3. cp/mv destination: last non-flag argument
|
|
601
|
+
const cpMvMatch = command.match(/\b(cp|mv)\s+(.*?)(?:[;|&]|$)/);
|
|
602
|
+
if (cpMvMatch) {
|
|
603
|
+
const args = cpMvMatch[2].trim().split(/\s+/).filter(a => !a.startsWith("-"));
|
|
604
|
+
if (args.length >= 2) {
|
|
605
|
+
const dest = args[args.length - 1].replace(/^["']|["']$/g, "");
|
|
606
|
+
if (dest && !dest.startsWith("/dev/") && !dest.startsWith("$")) {
|
|
607
|
+
paths.push(dest);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// 4. sed -i: in-place file editing — the target file is the last argument
|
|
612
|
+
const sedInPlace = command.match(/\bsed\s+[^;|&]*?-i/);
|
|
613
|
+
if (sedInPlace) {
|
|
614
|
+
const sedFull = command.match(/\bsed\s+(.*?)(?:\s*[;|&]|$)/);
|
|
615
|
+
if (sedFull) {
|
|
616
|
+
const tokens = sedFull[1].trim().split(/\s+/);
|
|
617
|
+
const lastToken = tokens[tokens.length - 1]?.replace(/^["']|["']$/g, "");
|
|
618
|
+
if (lastToken && !lastToken.startsWith("/dev/") && !lastToken.startsWith("$") && !lastToken.startsWith("-")) {
|
|
619
|
+
paths.push(lastToken);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return [...new Set(paths)];
|
|
624
|
+
}
|
|
491
625
|
/** Convert string to kebab-case (matches agent-md.ts toKebabCase). */
|
|
492
626
|
function toKebabCase(s) {
|
|
493
627
|
return s
|