@evo-dev/core 0.0.1-alpha
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/assets/agents/review/code-reviewer/examples.md +19 -0
- package/assets/agents/review/code-reviewer/manifest.json +10 -0
- package/assets/agents/review/code-reviewer/prompt.md +59 -0
- package/assets/agents/review/code-reviewer/verification.md +11 -0
- package/assets/skills/coding/engineering-discipline/SKILL.md +63 -0
- package/assets/skills/coding/engineering-discipline/anti-patterns.md +21 -0
- package/assets/skills/coding/engineering-discipline/examples.md +19 -0
- package/assets/skills/coding/engineering-discipline/manifest.json +10 -0
- package/assets/skills/coding/engineering-discipline/verification.md +11 -0
- package/assets/workflows/rd-bug-fix/WORKFLOW.json +45 -0
- package/assets/workflows/rd-code-review/WORKFLOW.json +45 -0
- package/assets/workflows/rd-docs-update/WORKFLOW.json +45 -0
- package/assets/workflows/rd-feature-implementation/WORKFLOW.json +45 -0
- package/assets/workflows/rd-refactor/WORKFLOW.json +45 -0
- package/assets/workflows/rd-release-readiness/WORKFLOW.json +49 -0
- package/assets/workflows/rd-security-boundary-review/WORKFLOW.json +45 -0
- package/assets/workflows/rd-test-generation/WORKFLOW.json +45 -0
- package/dist/assets/index.js +209 -0
- package/dist/config/index.js +601 -0
- package/dist/index.js +4879 -0
- package/dist/plugins/index.js +265 -0
- package/package.json +30 -0
- package/src/.gitkeep +0 -0
- package/src/agents/index.ts +561 -0
- package/src/assets/errors.ts +21 -0
- package/src/assets/index.ts +18 -0
- package/src/assets/manifest.ts +109 -0
- package/src/assets/scanner.ts +189 -0
- package/src/config/errors.ts +21 -0
- package/src/config/index.ts +26 -0
- package/src/config/paths.ts +43 -0
- package/src/config/registry.ts +84 -0
- package/src/config/settings.ts +212 -0
- package/src/config/state.ts +130 -0
- package/src/config/store.ts +166 -0
- package/src/daemon/index.ts +414 -0
- package/src/hooks/index.ts +1023 -0
- package/src/index.ts +14 -0
- package/src/learning/index.ts +714 -0
- package/src/observability/index.ts +272 -0
- package/src/pack/index.ts +779 -0
- package/src/plugins/capabilities.ts +347 -0
- package/src/plugins/index.ts +41 -0
- package/src/plugins/registry.ts +60 -0
- package/src/plugins/types.ts +123 -0
- package/src/project/index.ts +507 -0
- package/src/protected-zones/index.ts +137 -0
- package/src/sync/index.ts +7 -0
- package/src/sync/orchestrator.ts +298 -0
- package/src/task/index.ts +840 -0
- package/src/workflow/index.ts +137 -0
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type TaskContract,
|
|
6
|
+
createTaskContract,
|
|
7
|
+
routeTaskContract,
|
|
8
|
+
writeTaskContract,
|
|
9
|
+
} from "../task/index.ts";
|
|
10
|
+
|
|
11
|
+
export const CANONICAL_HOOK_EVENT_TYPES = [
|
|
12
|
+
"SessionStart",
|
|
13
|
+
"UserPromptSubmit",
|
|
14
|
+
"UserPromptExpansion",
|
|
15
|
+
"PreToolUse",
|
|
16
|
+
"PermissionRequest",
|
|
17
|
+
"PostToolUse",
|
|
18
|
+
"PostToolUseFailure",
|
|
19
|
+
"PostToolBatch",
|
|
20
|
+
"PermissionDenied",
|
|
21
|
+
"SubagentStart",
|
|
22
|
+
"Stop",
|
|
23
|
+
"StopFailure",
|
|
24
|
+
"TeammateIdle",
|
|
25
|
+
"SubagentStop",
|
|
26
|
+
"TaskCreated",
|
|
27
|
+
"TaskCompleted",
|
|
28
|
+
"PreCompact",
|
|
29
|
+
"PostCompact",
|
|
30
|
+
"SessionEnd",
|
|
31
|
+
"ConfigChange",
|
|
32
|
+
"CwdChanged",
|
|
33
|
+
"FileChanged",
|
|
34
|
+
"WorktreeCreate",
|
|
35
|
+
"WorktreeRemove",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export type CanonicalHookEventType = (typeof CANONICAL_HOOK_EVENT_TYPES)[number];
|
|
39
|
+
export type LegacyHookEventType = "AgentStop";
|
|
40
|
+
export type HookInputEventType = CanonicalHookEventType | LegacyHookEventType;
|
|
41
|
+
export type CodeAgentHookTarget = "claude" | "codex";
|
|
42
|
+
export type CommandRiskClass =
|
|
43
|
+
| "read-only"
|
|
44
|
+
| "test-command"
|
|
45
|
+
| "write"
|
|
46
|
+
| "delete"
|
|
47
|
+
| "network"
|
|
48
|
+
| "publish"
|
|
49
|
+
| "credential-sensitive"
|
|
50
|
+
| "path-escaping"
|
|
51
|
+
| "unknown";
|
|
52
|
+
export type HookDecisionAction = "allow" | "warn" | "ask-user" | "block";
|
|
53
|
+
|
|
54
|
+
export interface HookSettings {
|
|
55
|
+
enabled: boolean;
|
|
56
|
+
targets: Record<CodeAgentHookTarget, HookTargetSettings>;
|
|
57
|
+
observability: {
|
|
58
|
+
metadataOnly: true;
|
|
59
|
+
rawPayloadStorage: false;
|
|
60
|
+
appendEvents: false;
|
|
61
|
+
};
|
|
62
|
+
learning: {
|
|
63
|
+
emitCandidates: false;
|
|
64
|
+
writeMemory: false;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface HookTargetSettings {
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
events: Record<CanonicalHookEventType, boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface HookEventV1 {
|
|
74
|
+
version: 1;
|
|
75
|
+
eventId: string;
|
|
76
|
+
type: CanonicalHookEventType;
|
|
77
|
+
source: {
|
|
78
|
+
pluginId: string;
|
|
79
|
+
agent: string;
|
|
80
|
+
sessionIdHash: string | null;
|
|
81
|
+
rawPayloadStored: false;
|
|
82
|
+
};
|
|
83
|
+
time: {
|
|
84
|
+
occurredAt: string | null;
|
|
85
|
+
receivedAt: string;
|
|
86
|
+
};
|
|
87
|
+
scope: {
|
|
88
|
+
taskId: string | null;
|
|
89
|
+
projectId: string | null;
|
|
90
|
+
cwdPolicy: "metadata-only";
|
|
91
|
+
projectContextOptedIn: false;
|
|
92
|
+
};
|
|
93
|
+
payload: {
|
|
94
|
+
summary: string;
|
|
95
|
+
metadata: Record<string, string | number | boolean | string[]>;
|
|
96
|
+
redactions: string[];
|
|
97
|
+
redactionCount: number;
|
|
98
|
+
rawContentIncluded: false;
|
|
99
|
+
};
|
|
100
|
+
policy: {
|
|
101
|
+
classification: "local-private";
|
|
102
|
+
allowedUses: Array<"observability" | "safety-check" | "workflow-suggestion">;
|
|
103
|
+
learningAllowed: false;
|
|
104
|
+
externalUploadAllowed: false;
|
|
105
|
+
};
|
|
106
|
+
decision: {
|
|
107
|
+
action: HookDecisionAction;
|
|
108
|
+
reason: string;
|
|
109
|
+
requiresUserConfirmation: boolean;
|
|
110
|
+
};
|
|
111
|
+
warnings: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface NormalizeHookEventInput {
|
|
115
|
+
pluginId: string;
|
|
116
|
+
agent?: string;
|
|
117
|
+
type: HookInputEventType;
|
|
118
|
+
payload: Record<string, unknown>;
|
|
119
|
+
receivedAt?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface HookInstallDryRunPlan {
|
|
123
|
+
target: CodeAgentHookTarget;
|
|
124
|
+
settings: HookSettings;
|
|
125
|
+
plannedWrites: Array<{
|
|
126
|
+
action: "merge-with-backup" | "create";
|
|
127
|
+
targetPath: string;
|
|
128
|
+
reason: string;
|
|
129
|
+
}>;
|
|
130
|
+
warnings: string[];
|
|
131
|
+
blockers: string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface HookRuntimeSessionBinding {
|
|
135
|
+
version: 1;
|
|
136
|
+
target: CodeAgentHookTarget;
|
|
137
|
+
sessionKey: string;
|
|
138
|
+
taskId: string | null;
|
|
139
|
+
contractPath: string | null;
|
|
140
|
+
cwd: string | null;
|
|
141
|
+
route: TaskContract["route"] | null;
|
|
142
|
+
updatedAt: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface HookRuntimeResult {
|
|
146
|
+
target: CodeAgentHookTarget;
|
|
147
|
+
event: CanonicalHookEventType;
|
|
148
|
+
enabled: boolean;
|
|
149
|
+
output: Record<string, unknown> | null;
|
|
150
|
+
stateWrites: string[];
|
|
151
|
+
summary: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface HandleHookRuntimeInput {
|
|
155
|
+
target: CodeAgentHookTarget;
|
|
156
|
+
homeDir: string;
|
|
157
|
+
settings: HookSettings;
|
|
158
|
+
event: HookEventV1;
|
|
159
|
+
rawPayload: Record<string, unknown>;
|
|
160
|
+
receivedAt?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const DEFAULT_EVENT_SETTINGS: Record<CanonicalHookEventType, boolean> = {
|
|
164
|
+
SessionStart: false,
|
|
165
|
+
UserPromptSubmit: false,
|
|
166
|
+
UserPromptExpansion: false,
|
|
167
|
+
PreToolUse: false,
|
|
168
|
+
PermissionRequest: false,
|
|
169
|
+
PostToolUse: false,
|
|
170
|
+
PostToolUseFailure: false,
|
|
171
|
+
PostToolBatch: false,
|
|
172
|
+
PermissionDenied: false,
|
|
173
|
+
SubagentStart: false,
|
|
174
|
+
Stop: false,
|
|
175
|
+
StopFailure: false,
|
|
176
|
+
TeammateIdle: false,
|
|
177
|
+
SubagentStop: false,
|
|
178
|
+
TaskCreated: false,
|
|
179
|
+
TaskCompleted: false,
|
|
180
|
+
PreCompact: false,
|
|
181
|
+
PostCompact: false,
|
|
182
|
+
SessionEnd: false,
|
|
183
|
+
ConfigChange: false,
|
|
184
|
+
CwdChanged: false,
|
|
185
|
+
FileChanged: false,
|
|
186
|
+
WorktreeCreate: false,
|
|
187
|
+
WorktreeRemove: false,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const SENSITIVE_TEXT_PATTERN =
|
|
191
|
+
/https?:\/\/\S+|\b(secret|token|password|passwd|private|internal|api[_-]?key|apikey|credential|credentials|\.env)\b/i;
|
|
192
|
+
const SOURCE_LIKE_PATTERN = /\b(function|class|import|export|const|let|var)\b.*[{};]/s;
|
|
193
|
+
|
|
194
|
+
export function createDefaultHookSettings(): HookSettings {
|
|
195
|
+
return {
|
|
196
|
+
enabled: false,
|
|
197
|
+
targets: {
|
|
198
|
+
claude: {
|
|
199
|
+
enabled: false,
|
|
200
|
+
events: { ...DEFAULT_EVENT_SETTINGS },
|
|
201
|
+
},
|
|
202
|
+
codex: {
|
|
203
|
+
enabled: false,
|
|
204
|
+
events: { ...DEFAULT_EVENT_SETTINGS },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
observability: {
|
|
208
|
+
metadataOnly: true,
|
|
209
|
+
rawPayloadStorage: false,
|
|
210
|
+
appendEvents: false,
|
|
211
|
+
},
|
|
212
|
+
learning: {
|
|
213
|
+
emitCandidates: false,
|
|
214
|
+
writeMemory: false,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function parseHookSettings(value: unknown): HookSettings {
|
|
220
|
+
const defaults = createDefaultHookSettings();
|
|
221
|
+
if (value === undefined || value === null) return defaults;
|
|
222
|
+
if (!isRecord(value)) throw new Error("Invalid hooks settings; expected object.");
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
enabled: optionalBoolean(value.enabled, defaults.enabled, "hooks.enabled"),
|
|
226
|
+
targets: {
|
|
227
|
+
claude: parseHookTargetSettings(value.targets, defaults.targets.claude, "claude"),
|
|
228
|
+
codex: parseHookTargetSettings(value.targets, defaults.targets.codex, "codex"),
|
|
229
|
+
},
|
|
230
|
+
observability: {
|
|
231
|
+
metadataOnly: true,
|
|
232
|
+
rawPayloadStorage: false,
|
|
233
|
+
appendEvents: false,
|
|
234
|
+
},
|
|
235
|
+
learning: {
|
|
236
|
+
emitCandidates: false,
|
|
237
|
+
writeMemory: false,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function normalizeHookEvent(input: NormalizeHookEventInput): HookEventV1 {
|
|
243
|
+
const warnings: string[] = [];
|
|
244
|
+
const type = normalizeHookEventType(input.type, warnings);
|
|
245
|
+
const redactions: string[] = [];
|
|
246
|
+
const metadata = extractMetadata(type, input.payload, redactions);
|
|
247
|
+
const commandClass =
|
|
248
|
+
typeof metadata.commandClass === "string" ? metadata.commandClass : undefined;
|
|
249
|
+
const decision = decideHookPolicy(commandClass);
|
|
250
|
+
const sessionIdHash = hashOptionalIdentifier(input.payload.session_id ?? input.payload.sessionId);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
version: 1,
|
|
254
|
+
eventId: stableEventId(input.pluginId, type, sessionIdHash),
|
|
255
|
+
type,
|
|
256
|
+
source: {
|
|
257
|
+
pluginId: sanitizeScalar(input.pluginId, redactions),
|
|
258
|
+
agent: sanitizeScalar(input.agent ?? "unknown", redactions),
|
|
259
|
+
sessionIdHash,
|
|
260
|
+
rawPayloadStored: false,
|
|
261
|
+
},
|
|
262
|
+
time: {
|
|
263
|
+
occurredAt: optionalSanitizedString(
|
|
264
|
+
input.payload.timestamp ?? input.payload.occurredAt,
|
|
265
|
+
redactions,
|
|
266
|
+
),
|
|
267
|
+
receivedAt: input.receivedAt ?? "dry-run",
|
|
268
|
+
},
|
|
269
|
+
scope: {
|
|
270
|
+
taskId: optionalSanitizedString(input.payload.taskId, redactions),
|
|
271
|
+
projectId: optionalSanitizedString(input.payload.projectId, redactions),
|
|
272
|
+
cwdPolicy: "metadata-only",
|
|
273
|
+
projectContextOptedIn: false,
|
|
274
|
+
},
|
|
275
|
+
payload: {
|
|
276
|
+
summary: summarizeEvent(type, metadata),
|
|
277
|
+
metadata,
|
|
278
|
+
redactions,
|
|
279
|
+
redactionCount: redactions.length,
|
|
280
|
+
rawContentIncluded: false,
|
|
281
|
+
},
|
|
282
|
+
policy: {
|
|
283
|
+
classification: "local-private",
|
|
284
|
+
allowedUses: ["observability", "safety-check", "workflow-suggestion"],
|
|
285
|
+
learningAllowed: false,
|
|
286
|
+
externalUploadAllowed: false,
|
|
287
|
+
},
|
|
288
|
+
decision,
|
|
289
|
+
warnings,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function classifyCommandRisk(command: string | undefined): CommandRiskClass {
|
|
294
|
+
if (command === undefined || command.trim() === "") return "unknown";
|
|
295
|
+
const lower = command.toLowerCase();
|
|
296
|
+
if (/(^|\s)(\.\.\/|\/\.\.)/.test(lower)) return "path-escaping";
|
|
297
|
+
if (/\b(npm|pnpm|yarn|bun)\s+publish\b|\bgh\s+release\b/.test(lower)) return "publish";
|
|
298
|
+
if (/\b(curl|wget|scp|rsync|ssh|ftp)\b|https?:\/\//.test(lower)) return "network";
|
|
299
|
+
if (/\b(rm|rmdir|unlink)\b|--delete\b/.test(lower)) return "delete";
|
|
300
|
+
if (/\b(secret|token|password|passwd|credential|api[_-]?key)\b|(^|\s)\.env(\s|$)/.test(lower)) {
|
|
301
|
+
return "credential-sensitive";
|
|
302
|
+
}
|
|
303
|
+
if (/\b(vim|nano|tee|touch|mkdir|mv|cp|chmod|chown)\b|>/.test(lower)) return "write";
|
|
304
|
+
if (/\b(test|lint|typecheck|check)\b/.test(lower)) return "test-command";
|
|
305
|
+
if (/\b(ls|pwd|grep|rg|find|git status|git diff|cat)\b/.test(lower)) return "read-only";
|
|
306
|
+
return "unknown";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function formatHookInstallDryRun(plan: HookInstallDryRunPlan): string {
|
|
310
|
+
return [
|
|
311
|
+
"EvoDev hook install dry-run",
|
|
312
|
+
"",
|
|
313
|
+
`Target: ${plan.target}`,
|
|
314
|
+
"Mode: dry-run (no writes)",
|
|
315
|
+
`Hooks enabled by default: ${plan.settings.enabled}`,
|
|
316
|
+
"Selected events:",
|
|
317
|
+
...CANONICAL_HOOK_EVENT_TYPES.map(
|
|
318
|
+
(eventType) => ` - ${eventType}: ${plan.settings.targets[plan.target].events[eventType]}`,
|
|
319
|
+
),
|
|
320
|
+
"Boundaries:",
|
|
321
|
+
" - observability: metadata-only, appendEvents=false, rawPayloadStorage=false",
|
|
322
|
+
" - learning: emitCandidates=false, writeMemory=false",
|
|
323
|
+
" - external upload: false",
|
|
324
|
+
" - protected project writes: .claude/.codex/CLAUDE.md/AGENTS.md not targeted",
|
|
325
|
+
" - backup/merge: required before any future real install; dry-run writes nothing",
|
|
326
|
+
"Planned writes:",
|
|
327
|
+
...plan.plannedWrites.map(
|
|
328
|
+
(write) => ` - ${write.action}: ${write.targetPath} (${write.reason})`,
|
|
329
|
+
),
|
|
330
|
+
"Warnings:",
|
|
331
|
+
...(plan.warnings.length === 0
|
|
332
|
+
? [" - none"]
|
|
333
|
+
: plan.warnings.map((warning) => ` - ${warning}`)),
|
|
334
|
+
"Blockers:",
|
|
335
|
+
...(plan.blockers.length === 0
|
|
336
|
+
? [" - none"]
|
|
337
|
+
: plan.blockers.map((blocker) => ` - ${blocker}`)),
|
|
338
|
+
].join("\n");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function formatHookEventDryRun(event: HookEventV1): string {
|
|
342
|
+
return [
|
|
343
|
+
"EvoDev hook event dry-run",
|
|
344
|
+
"",
|
|
345
|
+
`Type: ${event.type}`,
|
|
346
|
+
`Plugin: ${event.source.pluginId}`,
|
|
347
|
+
`Summary: ${event.payload.summary}`,
|
|
348
|
+
`Decision: ${event.decision.action} (${event.decision.reason})`,
|
|
349
|
+
"Metadata:",
|
|
350
|
+
...Object.entries(event.payload.metadata).map(
|
|
351
|
+
([key, value]) => ` - ${key}: ${formatMetadataValue(value)}`,
|
|
352
|
+
),
|
|
353
|
+
"Redactions:",
|
|
354
|
+
...(event.payload.redactions.length === 0
|
|
355
|
+
? [" - none"]
|
|
356
|
+
: event.payload.redactions.map((redaction) => ` - ${redaction}`)),
|
|
357
|
+
"Warnings:",
|
|
358
|
+
...(event.warnings.length === 0
|
|
359
|
+
? [" - none"]
|
|
360
|
+
: event.warnings.map((warning) => ` - ${warning}`)),
|
|
361
|
+
].join("\n");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function resolveHookRuntimeSessionPaths(input: {
|
|
365
|
+
homeDir: string;
|
|
366
|
+
sessionKey: string;
|
|
367
|
+
}): { sessionDir: string; bindingPath: string; contractPath: string } {
|
|
368
|
+
const sessionDir = join(input.homeDir, ".evodev", "STATE", "hooks", "sessions", input.sessionKey);
|
|
369
|
+
return {
|
|
370
|
+
sessionDir,
|
|
371
|
+
bindingPath: join(sessionDir, "binding.json"),
|
|
372
|
+
contractPath: join(sessionDir, "contract.json"),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function handleHookRuntime(input: HandleHookRuntimeInput): Promise<HookRuntimeResult> {
|
|
377
|
+
const enabled = isHookEventEnabled(input.settings, input.target, input.event.type);
|
|
378
|
+
if (!enabled) {
|
|
379
|
+
return {
|
|
380
|
+
target: input.target,
|
|
381
|
+
event: input.event.type,
|
|
382
|
+
enabled,
|
|
383
|
+
output: null,
|
|
384
|
+
stateWrites: [],
|
|
385
|
+
summary: `Hook ${input.event.type} ignored because EvoDev hooks are disabled.`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (input.event.type === "SessionStart") return handleSessionStart(input);
|
|
390
|
+
if (input.event.type === "UserPromptSubmit") return handleUserPromptSubmit(input);
|
|
391
|
+
if (input.event.type === "PreToolUse") return handlePreToolUse(input);
|
|
392
|
+
if (input.event.type === "PostToolUse" || input.event.type === "PostToolUseFailure") {
|
|
393
|
+
return handlePostToolUse(input);
|
|
394
|
+
}
|
|
395
|
+
if (input.event.type === "PostToolBatch") return handleAdditionalContext(input, "PostToolBatch");
|
|
396
|
+
if (
|
|
397
|
+
input.event.type === "SubagentStart" ||
|
|
398
|
+
input.event.type === "TaskCreated" ||
|
|
399
|
+
input.event.type === "PermissionRequest"
|
|
400
|
+
) {
|
|
401
|
+
return handlePreToolUse(input);
|
|
402
|
+
}
|
|
403
|
+
if (
|
|
404
|
+
input.event.type === "Stop" ||
|
|
405
|
+
input.event.type === "SubagentStop" ||
|
|
406
|
+
input.event.type === "TaskCompleted" ||
|
|
407
|
+
input.event.type === "TeammateIdle"
|
|
408
|
+
) {
|
|
409
|
+
return handleCompletionGate(input);
|
|
410
|
+
}
|
|
411
|
+
if (input.event.type === "PreCompact") return handlePreCompact(input);
|
|
412
|
+
if (input.event.type === "SessionEnd") return handleSessionEnd(input);
|
|
413
|
+
return handleAdditionalContext(input, input.event.type);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function formatHookRuntimeOutput(result: HookRuntimeResult): string {
|
|
417
|
+
return result.output === null ? "" : `${JSON.stringify(result.output)}\n`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isHookEventEnabled(
|
|
421
|
+
settings: HookSettings,
|
|
422
|
+
target: CodeAgentHookTarget,
|
|
423
|
+
eventType: CanonicalHookEventType,
|
|
424
|
+
): boolean {
|
|
425
|
+
return (
|
|
426
|
+
settings.enabled === true &&
|
|
427
|
+
settings.targets[target]?.enabled === true &&
|
|
428
|
+
settings.targets[target]?.events[eventType] === true
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function handleSessionStart(input: HandleHookRuntimeInput): Promise<HookRuntimeResult> {
|
|
433
|
+
const binding = await readSessionBinding(input.homeDir, input.rawPayload);
|
|
434
|
+
const context = [
|
|
435
|
+
"EvoDev session initialized.",
|
|
436
|
+
binding?.contractPath
|
|
437
|
+
? `Active Task Contract: ${binding.contractPath}`
|
|
438
|
+
: "No active Task Contract yet.",
|
|
439
|
+
"User prompts will be routed through EvoDev before execution.",
|
|
440
|
+
].join(" ");
|
|
441
|
+
return createRuntimeResult(input, hookOutput(input.event.type, { additionalContext: context }), {
|
|
442
|
+
summary: "Session context prepared.",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function handleUserPromptSubmit(input: HandleHookRuntimeInput): Promise<HookRuntimeResult> {
|
|
447
|
+
const classification = classifyUserPrompt(input.rawPayload.prompt ?? input.rawPayload.userPrompt);
|
|
448
|
+
const sessionKey = resolveHookSessionKey(input.rawPayload);
|
|
449
|
+
const paths = resolveHookRuntimeSessionPaths({ homeDir: input.homeDir, sessionKey });
|
|
450
|
+
const contract = routeTaskContract(createHookTaskContract(input, classification));
|
|
451
|
+
const binding: HookRuntimeSessionBinding = {
|
|
452
|
+
version: 1,
|
|
453
|
+
target: input.target,
|
|
454
|
+
sessionKey,
|
|
455
|
+
taskId: contract.taskId,
|
|
456
|
+
contractPath: paths.contractPath,
|
|
457
|
+
cwd: optionalPayloadString(input.rawPayload.cwd),
|
|
458
|
+
route: contract.route,
|
|
459
|
+
updatedAt: input.receivedAt ?? new Date().toISOString(),
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
await writeTaskContract(paths.contractPath, contract, { overwrite: true });
|
|
463
|
+
await writeJsonFile(paths.bindingPath, binding);
|
|
464
|
+
|
|
465
|
+
const context = [
|
|
466
|
+
`EvoDev routed this request before ${formatHookTargetName(input.target)} execution.`,
|
|
467
|
+
`Task Contract: ${paths.contractPath}`,
|
|
468
|
+
`Mode: ${contract.route.mode ?? "unknown"}`,
|
|
469
|
+
`Workflow: ${contract.route.workflowId ?? "none"}`,
|
|
470
|
+
`Reason: ${contract.route.rationale}`,
|
|
471
|
+
"Follow the contract scope, anti-criteria, and verification plan before finishing.",
|
|
472
|
+
].join(" ");
|
|
473
|
+
|
|
474
|
+
return createRuntimeResult(input, hookOutput(input.event.type, { additionalContext: context }), {
|
|
475
|
+
summary: "User prompt routed through Task Contract.",
|
|
476
|
+
stateWrites: [paths.contractPath, paths.bindingPath],
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function handlePreToolUse(input: HandleHookRuntimeInput): Promise<HookRuntimeResult> {
|
|
481
|
+
const contract = await readActiveContract(input.homeDir, input.rawPayload);
|
|
482
|
+
const commandClass =
|
|
483
|
+
typeof input.event.payload.metadata.commandClass === "string"
|
|
484
|
+
? input.event.payload.metadata.commandClass
|
|
485
|
+
: undefined;
|
|
486
|
+
const protectedPath = findProtectedPath(input.rawPayload);
|
|
487
|
+
|
|
488
|
+
if (contract === null) {
|
|
489
|
+
return createRuntimeResult(
|
|
490
|
+
input,
|
|
491
|
+
hookOutput(input.event.type, {
|
|
492
|
+
permissionDecision: "ask",
|
|
493
|
+
permissionDecisionReason: "EvoDev requires an active Task Contract before tool use.",
|
|
494
|
+
additionalContext:
|
|
495
|
+
"Submit the user prompt through UserPromptSubmit to create the Task Contract first.",
|
|
496
|
+
}),
|
|
497
|
+
{ summary: "Tool use requires user confirmation because no active contract exists." },
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (protectedPath !== null) {
|
|
502
|
+
return createRuntimeResult(
|
|
503
|
+
input,
|
|
504
|
+
hookOutput(input.event.type, {
|
|
505
|
+
permissionDecision: "deny",
|
|
506
|
+
permissionDecisionReason: `EvoDev blocked access to protected project asset: ${protectedPath}`,
|
|
507
|
+
}),
|
|
508
|
+
{ summary: "Protected path access denied." },
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (
|
|
513
|
+
commandClass === "delete" ||
|
|
514
|
+
commandClass === "publish" ||
|
|
515
|
+
commandClass === "credential-sensitive" ||
|
|
516
|
+
commandClass === "path-escaping"
|
|
517
|
+
) {
|
|
518
|
+
return createRuntimeResult(
|
|
519
|
+
input,
|
|
520
|
+
hookOutput(input.event.type, {
|
|
521
|
+
permissionDecision: "deny",
|
|
522
|
+
permissionDecisionReason: `EvoDev blocked high-risk tool action: ${commandClass}`,
|
|
523
|
+
}),
|
|
524
|
+
{ summary: "High-risk tool action denied." },
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (commandClass === "network" || commandClass === "unknown" || commandClass === "write") {
|
|
529
|
+
return createRuntimeResult(
|
|
530
|
+
input,
|
|
531
|
+
hookOutput(input.event.type, {
|
|
532
|
+
permissionDecision: "ask",
|
|
533
|
+
permissionDecisionReason: `EvoDev requires confirmation for ${commandClass} action under ${contract.route.mode ?? "unrouted"} mode.`,
|
|
534
|
+
additionalContext: `Active Task Contract: ${contract.taskId}. Confirm scope before continuing.`,
|
|
535
|
+
}),
|
|
536
|
+
{ summary: "Tool use requires user confirmation." },
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return createRuntimeResult(
|
|
541
|
+
input,
|
|
542
|
+
hookOutput(input.event.type, {
|
|
543
|
+
permissionDecision: "allow",
|
|
544
|
+
permissionDecisionReason: `EvoDev allowed ${commandClass ?? "metadata-only"} action under Task Contract ${contract.taskId}.`,
|
|
545
|
+
}),
|
|
546
|
+
{ summary: "Tool use allowed by EvoDev policy." },
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function handlePostToolUse(input: HandleHookRuntimeInput): Promise<HookRuntimeResult> {
|
|
551
|
+
const contract = await readActiveContract(input.homeDir, input.rawPayload);
|
|
552
|
+
const binding = await readSessionBinding(input.homeDir, input.rawPayload);
|
|
553
|
+
if (contract === null || binding?.contractPath === null || binding?.contractPath === undefined) {
|
|
554
|
+
return handleAdditionalContext(input, input.event.type);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const status = optionalPayloadNumber(input.rawPayload.exit_code ?? input.rawPayload.exitCode);
|
|
558
|
+
const nextContract: TaskContract = {
|
|
559
|
+
...contract,
|
|
560
|
+
evidence: {
|
|
561
|
+
metadataOnly: true,
|
|
562
|
+
items: [
|
|
563
|
+
...contract.evidence.items,
|
|
564
|
+
{
|
|
565
|
+
type: "command-result",
|
|
566
|
+
id: input.event.eventId,
|
|
567
|
+
status: status === 0 ? "pass" : status === null ? "unknown" : "fail",
|
|
568
|
+
summary: input.event.payload.summary,
|
|
569
|
+
rawOutputStored: false,
|
|
570
|
+
sourceContentStored: false,
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
await writeTaskContract(binding.contractPath, nextContract, { overwrite: true });
|
|
576
|
+
|
|
577
|
+
return createRuntimeResult(
|
|
578
|
+
input,
|
|
579
|
+
hookOutput(input.event.type, {
|
|
580
|
+
additionalContext: `EvoDev recorded metadata-only evidence for Task Contract ${contract.taskId}. Raw output was not stored.`,
|
|
581
|
+
}),
|
|
582
|
+
{
|
|
583
|
+
summary: "Post-tool metadata evidence recorded.",
|
|
584
|
+
stateWrites: [binding.contractPath],
|
|
585
|
+
},
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function handleCompletionGate(input: HandleHookRuntimeInput): Promise<HookRuntimeResult> {
|
|
590
|
+
const contract = await readActiveContract(input.homeDir, input.rawPayload);
|
|
591
|
+
if (contract === null) return handleAdditionalContext(input, input.event.type);
|
|
592
|
+
|
|
593
|
+
const stopHookActive = input.rawPayload.stop_hook_active === true;
|
|
594
|
+
if (
|
|
595
|
+
!stopHookActive &&
|
|
596
|
+
contract.route.mode === "rigorous" &&
|
|
597
|
+
contract.evidence.items.length === 0
|
|
598
|
+
) {
|
|
599
|
+
return createRuntimeResult(
|
|
600
|
+
input,
|
|
601
|
+
{
|
|
602
|
+
decision: "block",
|
|
603
|
+
reason: "EvoDev rigorous mode requires metadata-only evidence before stopping.",
|
|
604
|
+
...hookOutput(input.event.type, {
|
|
605
|
+
additionalContext:
|
|
606
|
+
"Run the required verification or record metadata-only evidence before finishing.",
|
|
607
|
+
}),
|
|
608
|
+
},
|
|
609
|
+
{ summary: "Completion blocked until evidence exists." },
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return createRuntimeResult(
|
|
614
|
+
input,
|
|
615
|
+
hookOutput(input.event.type, {
|
|
616
|
+
additionalContext: `EvoDev completion gate checked Task Contract ${contract.taskId}. Evidence items: ${contract.evidence.items.length}.`,
|
|
617
|
+
}),
|
|
618
|
+
{ summary: "Completion gate checked." },
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function handlePreCompact(input: HandleHookRuntimeInput): Promise<HookRuntimeResult> {
|
|
623
|
+
const binding = await readSessionBinding(input.homeDir, input.rawPayload);
|
|
624
|
+
return createRuntimeResult(
|
|
625
|
+
input,
|
|
626
|
+
hookOutput(input.event.type, {
|
|
627
|
+
additionalContext: binding?.contractPath
|
|
628
|
+
? `Preserve EvoDev Task Contract reference across compaction: ${binding.contractPath}`
|
|
629
|
+
: "No EvoDev Task Contract is active for this session.",
|
|
630
|
+
}),
|
|
631
|
+
{ summary: "PreCompact context prepared." },
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function handleSessionEnd(input: HandleHookRuntimeInput): Promise<HookRuntimeResult> {
|
|
636
|
+
const binding = await readSessionBinding(input.homeDir, input.rawPayload);
|
|
637
|
+
if (binding === null)
|
|
638
|
+
return createRuntimeResult(input, null, { summary: "No session state to close." });
|
|
639
|
+
const nextBinding = {
|
|
640
|
+
...binding,
|
|
641
|
+
updatedAt: input.receivedAt ?? new Date().toISOString(),
|
|
642
|
+
};
|
|
643
|
+
const paths = resolveHookRuntimeSessionPaths({
|
|
644
|
+
homeDir: input.homeDir,
|
|
645
|
+
sessionKey: binding.sessionKey,
|
|
646
|
+
});
|
|
647
|
+
await writeJsonFile(paths.bindingPath, nextBinding);
|
|
648
|
+
return createRuntimeResult(input, null, {
|
|
649
|
+
summary: "Session state updated for SessionEnd.",
|
|
650
|
+
stateWrites: [paths.bindingPath],
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function handleAdditionalContext(
|
|
655
|
+
input: HandleHookRuntimeInput,
|
|
656
|
+
eventName: CanonicalHookEventType,
|
|
657
|
+
): Promise<HookRuntimeResult> {
|
|
658
|
+
return Promise.resolve(
|
|
659
|
+
createRuntimeResult(
|
|
660
|
+
input,
|
|
661
|
+
hookOutput(eventName, {
|
|
662
|
+
additionalContext: `EvoDev processed ${eventName} as metadata-only hook context.`,
|
|
663
|
+
}),
|
|
664
|
+
{ summary: `${eventName} processed as metadata-only context.` },
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function createRuntimeResult(
|
|
670
|
+
input: HandleHookRuntimeInput,
|
|
671
|
+
output: Record<string, unknown> | null,
|
|
672
|
+
options: { summary: string; stateWrites?: string[] },
|
|
673
|
+
): HookRuntimeResult {
|
|
674
|
+
return {
|
|
675
|
+
target: input.target,
|
|
676
|
+
event: input.event.type,
|
|
677
|
+
enabled: true,
|
|
678
|
+
output,
|
|
679
|
+
stateWrites: options.stateWrites ?? [],
|
|
680
|
+
summary: options.summary,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function hookOutput(
|
|
685
|
+
eventName: CanonicalHookEventType,
|
|
686
|
+
output: Record<string, unknown>,
|
|
687
|
+
): Record<string, unknown> {
|
|
688
|
+
return {
|
|
689
|
+
hookSpecificOutput: {
|
|
690
|
+
hookEventName: eventName,
|
|
691
|
+
...output,
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function createHookTaskContract(
|
|
697
|
+
input: HandleHookRuntimeInput,
|
|
698
|
+
classification: ReturnType<typeof classifyUserPrompt>,
|
|
699
|
+
): TaskContract {
|
|
700
|
+
const sessionKey = resolveHookSessionKey(input.rawPayload);
|
|
701
|
+
const targetName = formatHookTargetName(input.target);
|
|
702
|
+
const contract = createTaskContract({
|
|
703
|
+
title: `${targetName} hook task ${sessionKey}`,
|
|
704
|
+
summary: `${targetName} prompt classified as ${classification.kind}; raw prompt not stored.`,
|
|
705
|
+
projectId: null,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
...contract,
|
|
710
|
+
currentState: {
|
|
711
|
+
summary: `UserPromptSubmit received through ${targetName} hooks; raw prompt omitted.`,
|
|
712
|
+
evidenceRefs: [],
|
|
713
|
+
},
|
|
714
|
+
targetState: {
|
|
715
|
+
summary: `Complete the ${classification.kind} task through EvoDev-controlled workflow.`,
|
|
716
|
+
nonGoals: ["Do not store raw prompts, transcripts, source content, secrets, or raw output."],
|
|
717
|
+
constraints: [
|
|
718
|
+
`prompt-kind:${classification.kind}`,
|
|
719
|
+
...classification.riskTerms.map((term) => `risk:${term}`),
|
|
720
|
+
],
|
|
721
|
+
},
|
|
722
|
+
scope: {
|
|
723
|
+
...contract.scope,
|
|
724
|
+
requiresUserConfirmation: classification.riskTerms,
|
|
725
|
+
},
|
|
726
|
+
context: {
|
|
727
|
+
...contract.context,
|
|
728
|
+
assumptions: [
|
|
729
|
+
`hook-session:${sessionKey}`,
|
|
730
|
+
`cwd:${optionalPayloadString(input.rawPayload.cwd) ?? "unknown"}`,
|
|
731
|
+
],
|
|
732
|
+
openQuestions: classification.needsClarification
|
|
733
|
+
? ["User request may need clarification before broad changes."]
|
|
734
|
+
: [],
|
|
735
|
+
},
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function formatHookTargetName(target: CodeAgentHookTarget): string {
|
|
740
|
+
return target === "codex" ? "Codex" : "Claude";
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function classifyUserPrompt(value: unknown): {
|
|
744
|
+
kind: string;
|
|
745
|
+
riskTerms: string[];
|
|
746
|
+
needsClarification: boolean;
|
|
747
|
+
} {
|
|
748
|
+
const text = typeof value === "string" ? value.toLowerCase() : "";
|
|
749
|
+
const riskTerms = [
|
|
750
|
+
"security",
|
|
751
|
+
"release",
|
|
752
|
+
"publish",
|
|
753
|
+
"hook",
|
|
754
|
+
"memory",
|
|
755
|
+
"learning",
|
|
756
|
+
"secret",
|
|
757
|
+
"privacy",
|
|
758
|
+
].filter((term) => text.includes(term));
|
|
759
|
+
let kind = "feature";
|
|
760
|
+
if (/\bbug|fix|error|failed|failure\b/.test(text)) kind = "bugfix";
|
|
761
|
+
if (/\brefactor|migration|migrate\b/.test(text)) kind = "refactor";
|
|
762
|
+
if (/\breview|audit\b/.test(text)) kind = "review";
|
|
763
|
+
if (/\btest|coverage\b/.test(text)) kind = "test";
|
|
764
|
+
if (/\bdoc|readme|guide\b/.test(text)) kind = "docs";
|
|
765
|
+
if (riskTerms.includes("security") || riskTerms.includes("privacy")) kind = "security";
|
|
766
|
+
if (riskTerms.includes("release") || riskTerms.includes("publish")) kind = "release";
|
|
767
|
+
return {
|
|
768
|
+
kind,
|
|
769
|
+
riskTerms,
|
|
770
|
+
needsClarification: text.trim().length < 12 || /\bmaybe|unclear|not sure\b/.test(text),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function readActiveContract(
|
|
775
|
+
homeDir: string,
|
|
776
|
+
payload: Record<string, unknown>,
|
|
777
|
+
): Promise<TaskContract | null> {
|
|
778
|
+
const binding = await readSessionBinding(homeDir, payload);
|
|
779
|
+
if (binding?.contractPath === null || binding?.contractPath === undefined) return null;
|
|
780
|
+
try {
|
|
781
|
+
return JSON.parse(await readFile(binding.contractPath, "utf8")) as TaskContract;
|
|
782
|
+
} catch (error) {
|
|
783
|
+
if (isNotFoundError(error)) return null;
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function readSessionBinding(
|
|
789
|
+
homeDir: string,
|
|
790
|
+
payload: Record<string, unknown>,
|
|
791
|
+
): Promise<HookRuntimeSessionBinding | null> {
|
|
792
|
+
const sessionKey = resolveHookSessionKey(payload);
|
|
793
|
+
const paths = resolveHookRuntimeSessionPaths({ homeDir, sessionKey });
|
|
794
|
+
try {
|
|
795
|
+
return JSON.parse(await readFile(paths.bindingPath, "utf8")) as HookRuntimeSessionBinding;
|
|
796
|
+
} catch (error) {
|
|
797
|
+
if (isNotFoundError(error)) return null;
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function resolveHookSessionKey(payload: Record<string, unknown>): string {
|
|
803
|
+
const sessionId = optionalPayloadString(payload.session_id ?? payload.sessionId);
|
|
804
|
+
const cwd = optionalPayloadString(payload.cwd);
|
|
805
|
+
const source = sessionId ?? cwd ?? "local";
|
|
806
|
+
return `session-${createHash("sha256").update(source).digest("hex").slice(0, 16)}`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function writeJsonFile(path: string, value: unknown): Promise<void> {
|
|
810
|
+
await mkdir(dirname(path), { recursive: true });
|
|
811
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function findProtectedPath(payload: Record<string, unknown>): string | null {
|
|
815
|
+
const candidates = collectStringValues(payload).filter((value) => value.length < 500);
|
|
816
|
+
for (const candidate of candidates) {
|
|
817
|
+
if (
|
|
818
|
+
candidate === "CLAUDE.md" ||
|
|
819
|
+
candidate === "AGENTS.md" ||
|
|
820
|
+
candidate.includes("/CLAUDE.md") ||
|
|
821
|
+
candidate.includes("/AGENTS.md") ||
|
|
822
|
+
candidate.includes(".claude/") ||
|
|
823
|
+
candidate.includes(".codex/")
|
|
824
|
+
) {
|
|
825
|
+
return candidate;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function collectStringValues(value: unknown): string[] {
|
|
832
|
+
if (typeof value === "string") return [value];
|
|
833
|
+
if (Array.isArray(value)) return value.flatMap((item) => collectStringValues(item));
|
|
834
|
+
if (isRecord(value)) return Object.values(value).flatMap((item) => collectStringValues(item));
|
|
835
|
+
return [];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function optionalPayloadString(value: unknown): string | null {
|
|
839
|
+
return typeof value === "string" && value.length > 0 ? value.slice(0, 300) : null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function optionalPayloadNumber(value: unknown): number | null {
|
|
843
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function isNotFoundError(error: unknown): boolean {
|
|
847
|
+
return (
|
|
848
|
+
error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function normalizeHookEventType(
|
|
853
|
+
type: HookInputEventType,
|
|
854
|
+
warnings: string[],
|
|
855
|
+
): CanonicalHookEventType {
|
|
856
|
+
if (type === "AgentStop") {
|
|
857
|
+
warnings.push("Legacy AgentStop event normalized to SubagentStop.");
|
|
858
|
+
return "SubagentStop";
|
|
859
|
+
}
|
|
860
|
+
if ((CANONICAL_HOOK_EVENT_TYPES as readonly string[]).includes(type)) return type;
|
|
861
|
+
throw new Error(`Unsupported hook event type: ${type}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function parseHookTargetSettings(
|
|
865
|
+
value: unknown,
|
|
866
|
+
defaults: HookTargetSettings,
|
|
867
|
+
target: CodeAgentHookTarget,
|
|
868
|
+
): HookTargetSettings {
|
|
869
|
+
const targets = isRecord(value) ? value : {};
|
|
870
|
+
const targetSettings = isRecord(targets[target]) ? targets[target] : {};
|
|
871
|
+
const events = isRecord(targetSettings.events) ? targetSettings.events : {};
|
|
872
|
+
const parsedEvents: Record<CanonicalHookEventType, boolean> = { ...defaults.events };
|
|
873
|
+
for (const eventType of CANONICAL_HOOK_EVENT_TYPES) {
|
|
874
|
+
parsedEvents[eventType] = optionalBoolean(
|
|
875
|
+
events[eventType],
|
|
876
|
+
defaults.events[eventType],
|
|
877
|
+
`hooks.targets.${target}.events.${eventType}`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
enabled: optionalBoolean(
|
|
882
|
+
targetSettings.enabled,
|
|
883
|
+
defaults.enabled,
|
|
884
|
+
`hooks.targets.${target}.enabled`,
|
|
885
|
+
),
|
|
886
|
+
events: parsedEvents,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function extractMetadata(
|
|
891
|
+
type: CanonicalHookEventType,
|
|
892
|
+
payload: Record<string, unknown>,
|
|
893
|
+
redactions: string[],
|
|
894
|
+
): Record<string, string | number | boolean | string[]> {
|
|
895
|
+
const metadata: Record<string, string | number | boolean | string[]> = {
|
|
896
|
+
rawContentIncluded: false,
|
|
897
|
+
};
|
|
898
|
+
const toolInput = isRecord(payload.tool_input)
|
|
899
|
+
? payload.tool_input
|
|
900
|
+
: isRecord(payload.toolInput)
|
|
901
|
+
? payload.toolInput
|
|
902
|
+
: {};
|
|
903
|
+
const toolName = optionalSanitizedString(payload.tool_name ?? payload.toolName, redactions);
|
|
904
|
+
if (toolName !== null) metadata.toolName = toolName;
|
|
905
|
+
const rawCommand =
|
|
906
|
+
typeof payload.command === "string"
|
|
907
|
+
? payload.command
|
|
908
|
+
: typeof toolInput.command === "string"
|
|
909
|
+
? toolInput.command
|
|
910
|
+
: undefined;
|
|
911
|
+
const command = optionalSanitizedString(rawCommand, redactions, { classifyOnly: true });
|
|
912
|
+
if (rawCommand !== undefined) redactions.push("raw-command");
|
|
913
|
+
if (
|
|
914
|
+
type === "PreToolUse" ||
|
|
915
|
+
type === "PostToolUse" ||
|
|
916
|
+
type === "PostToolUseFailure" ||
|
|
917
|
+
type === "PermissionRequest" ||
|
|
918
|
+
type === "PermissionDenied"
|
|
919
|
+
) {
|
|
920
|
+
const commandClass = classifyCommandRisk(command ?? undefined);
|
|
921
|
+
metadata.commandClass = commandClass;
|
|
922
|
+
}
|
|
923
|
+
const exitCode = optionalNumber(payload.exit_code ?? payload.exitCode);
|
|
924
|
+
if (exitCode !== null) metadata.exitCode = exitCode;
|
|
925
|
+
const status = optionalSanitizedString(payload.status, redactions);
|
|
926
|
+
if (status !== null) metadata.status = status;
|
|
927
|
+
const redactedPrompt = payload.prompt ?? payload.userPrompt;
|
|
928
|
+
if (redactedPrompt !== undefined) redactions.push("raw-prompt");
|
|
929
|
+
const rawOutput = payload.stdout ?? payload.stderr ?? payload.output;
|
|
930
|
+
if (rawOutput !== undefined) redactions.push("raw-command-output");
|
|
931
|
+
return metadata;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function decideHookPolicy(commandClass: string | undefined): HookEventV1["decision"] {
|
|
935
|
+
if (
|
|
936
|
+
commandClass === "delete" ||
|
|
937
|
+
commandClass === "network" ||
|
|
938
|
+
commandClass === "publish" ||
|
|
939
|
+
commandClass === "credential-sensitive" ||
|
|
940
|
+
commandClass === "path-escaping" ||
|
|
941
|
+
commandClass === "unknown"
|
|
942
|
+
) {
|
|
943
|
+
return {
|
|
944
|
+
action: "ask-user",
|
|
945
|
+
reason: `Command risk requires explicit confirmation: ${commandClass}`,
|
|
946
|
+
requiresUserConfirmation: true,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
if (commandClass === "write") {
|
|
950
|
+
return {
|
|
951
|
+
action: "warn",
|
|
952
|
+
reason: "Write-like command requires scope review.",
|
|
953
|
+
requiresUserConfirmation: true,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
action: "allow",
|
|
958
|
+
reason: "No policy violation detected.",
|
|
959
|
+
requiresUserConfirmation: false,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function summarizeEvent(
|
|
964
|
+
type: CanonicalHookEventType,
|
|
965
|
+
metadata: Record<string, string | number | boolean | string[]>,
|
|
966
|
+
): string {
|
|
967
|
+
if (type === "PreToolUse") return `Tool use requested (${metadata.commandClass ?? "unknown"}).`;
|
|
968
|
+
if (type === "PostToolUse") return "Tool use completed with metadata-only result.";
|
|
969
|
+
if (type === "UserPromptSubmit") return "User prompt submitted; raw prompt redacted.";
|
|
970
|
+
if (type === "SubagentStop") return "Subagent stopped; transcript omitted.";
|
|
971
|
+
return `${type} received; metadata-only dry-run.`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function optionalSanitizedString(
|
|
975
|
+
value: unknown,
|
|
976
|
+
redactions: string[],
|
|
977
|
+
options: { classifyOnly?: boolean } = {},
|
|
978
|
+
): string | null {
|
|
979
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
980
|
+
if (SENSITIVE_TEXT_PATTERN.test(value) || SOURCE_LIKE_PATTERN.test(value)) {
|
|
981
|
+
redactions.push(options.classifyOnly ? "sensitive-command" : "sensitive-text");
|
|
982
|
+
return options.classifyOnly ? value : "[redacted]";
|
|
983
|
+
}
|
|
984
|
+
return value.slice(0, 120);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function sanitizeScalar(value: string, redactions: string[]): string {
|
|
988
|
+
return optionalSanitizedString(value, redactions) ?? "unknown";
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function stableEventId(
|
|
992
|
+
pluginId: string,
|
|
993
|
+
type: CanonicalHookEventType,
|
|
994
|
+
sessionIdHash: string | null,
|
|
995
|
+
): string {
|
|
996
|
+
const stableSessionPart = sessionIdHash ?? "local";
|
|
997
|
+
return `hook-${pluginId}-${type}-${stableSessionPart}`
|
|
998
|
+
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
999
|
+
.slice(0, 100);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function hashOptionalIdentifier(value: unknown): string | null {
|
|
1003
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
1004
|
+
return `sha256-${createHash("sha256").update(value).digest("hex").slice(0, 16)}`;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function optionalBoolean(value: unknown, fallback: boolean, path: string): boolean {
|
|
1008
|
+
if (value === undefined) return fallback;
|
|
1009
|
+
if (typeof value !== "boolean") throw new Error(`Invalid ${path}; expected boolean.`);
|
|
1010
|
+
return value;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function optionalNumber(value: unknown): number | null {
|
|
1014
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function formatMetadataValue(value: string | number | boolean | string[]): string {
|
|
1018
|
+
return Array.isArray(value) ? value.join(",") : String(value);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1022
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1023
|
+
}
|