@peterxiaoyang/superspec 0.1.7 → 0.1.9
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/README.md +16 -1
- package/adapters/codex/install-map.json +5 -0
- package/bin/superspec-hook.js +4 -0
- package/dist/src/archive.js +2 -0
- package/dist/src/cli_args.d.ts +5 -0
- package/dist/src/cli_args.js +41 -0
- package/dist/src/core.js +36 -0
- package/dist/src/evidence.js +3 -0
- package/dist/src/gates.js +2 -2
- package/dist/src/hooks/adapter.d.ts +5 -0
- package/dist/src/hooks/adapter.js +311 -0
- package/dist/src/hooks/guard_api.d.ts +12 -0
- package/dist/src/hooks/guard_api.js +797 -0
- package/dist/src/hooks/health.d.ts +4 -0
- package/dist/src/hooks/health.js +98 -0
- package/dist/src/hooks/policy_event.d.ts +41 -0
- package/dist/src/hooks/policy_event.js +2624 -0
- package/dist/src/hooks/types.d.ts +72 -0
- package/dist/src/hooks/types.js +1 -0
- package/dist/src/hooks/validation.d.ts +3 -0
- package/dist/src/hooks/validation.js +70 -0
- package/dist/src/packet_measure.js +67 -102
- package/dist/superspec_hook.d.ts +4 -0
- package/dist/superspec_hook.js +35 -0
- package/package.json +7 -2
- package/schemas/hook-event.schema.json +27 -0
- package/templates/hooks/codex-hooks.json +61 -0
- package/templates/workflow/skills/superspec-apply/SKILL.md +7 -0
- package/templates/workflow/skills/superspec-archive/SKILL.md +8 -0
- package/templates/workflow/skills/superspec-explore/SKILL.md +7 -0
- package/templates/workflow/skills/superspec-propose/SKILL.md +7 -0
- package/templates/workflow/skills/superspec-review/SKILL.md +7 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, realpathSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { GUARD_VERSION, GuardError, isObject, reason, runtime, sha256_text, toPosix, trustWarnings, } from "../util.js";
|
|
4
|
+
import { artifact_status_map, repo_root_from_cwd } from "../openspec.js";
|
|
5
|
+
import { index_evidence } from "../evidence.js";
|
|
6
|
+
import { superspec_dir } from "../paths.js";
|
|
7
|
+
import { parse_tasks, splitList } from "../tasks.js";
|
|
8
|
+
import { check_archive_ready, check_review_complete, check_task_complete, check_task_edit, check_task_reopen, } from "../gates.js";
|
|
9
|
+
import { check_archived, find_archived_change } from "../archive.js";
|
|
10
|
+
import { state_corrupt_reasons, with_state_lock } from "../state.js";
|
|
11
|
+
import { HOOK_ADAPTER_VERSION } from "./types.js";
|
|
12
|
+
import { hookManifestHash, managedHooksManifestPresent } from "./health.js";
|
|
13
|
+
import { anyPathInScope, cleanRelPath, eventContentHash, extractWriteIntent, isSuperSpecTrustRootPath, normalizeHookEvent, pinnedFileRef, readHookEventRef, renderReasons, } from "./policy_event.js";
|
|
14
|
+
const SESSION_TTL_MS = 60 * 60 * 1000;
|
|
15
|
+
function loadHookContext(change) {
|
|
16
|
+
const [status, repoRoot, changeRoot, evidences] = runtime.load_context(change);
|
|
17
|
+
return { change, status, repoRoot, changeRoot, evidences, stateCorruptReasons: state_corrupt_reasons(changeRoot) };
|
|
18
|
+
}
|
|
19
|
+
function fallbackHookStatus(repoRoot, changeRoot) {
|
|
20
|
+
return {
|
|
21
|
+
changeRoot,
|
|
22
|
+
planningHome: { root: repoRoot },
|
|
23
|
+
artifacts: [],
|
|
24
|
+
applyRequires: [],
|
|
25
|
+
schemaName: "spec-driven",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function loadArchivedHookContext(change) {
|
|
29
|
+
const repoRoot = repo_root_from_cwd();
|
|
30
|
+
const archivedRoot = find_archived_change(repoRoot, change);
|
|
31
|
+
const changeRoot = archivedRoot ?? join(repoRoot, "openspec", "changes", change);
|
|
32
|
+
return {
|
|
33
|
+
change,
|
|
34
|
+
status: fallbackHookStatus(repoRoot, changeRoot),
|
|
35
|
+
repoRoot,
|
|
36
|
+
changeRoot,
|
|
37
|
+
evidences: index_evidence(changeRoot),
|
|
38
|
+
stateCorruptReasons: state_corrupt_reasons(changeRoot),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function hookRuntimeDir(changeRoot) {
|
|
42
|
+
return join(superspec_dir(changeRoot), "hook-runtime");
|
|
43
|
+
}
|
|
44
|
+
function activeSessionsDir(changeRoot) {
|
|
45
|
+
return join(hookRuntimeDir(changeRoot), "active-sessions");
|
|
46
|
+
}
|
|
47
|
+
function hookAuditDir(changeRoot) {
|
|
48
|
+
return join(superspec_dir(changeRoot), "evidence", "hook-audit");
|
|
49
|
+
}
|
|
50
|
+
function rawDir(changeRoot) {
|
|
51
|
+
return join(superspec_dir(changeRoot), "raw");
|
|
52
|
+
}
|
|
53
|
+
function runlogPath(changeRoot) {
|
|
54
|
+
return join(superspec_dir(changeRoot), "subagent-runlog.jsonl");
|
|
55
|
+
}
|
|
56
|
+
function sessionFile(changeRoot, sessionId) {
|
|
57
|
+
const digest = sha256_text(sessionId).slice("sha256:".length, "sha256:".length + 32);
|
|
58
|
+
return join(activeSessionsDir(changeRoot), `${digest}.json`);
|
|
59
|
+
}
|
|
60
|
+
function nowIso() {
|
|
61
|
+
return new Date().toISOString();
|
|
62
|
+
}
|
|
63
|
+
function expiresIso() {
|
|
64
|
+
return new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
|
65
|
+
}
|
|
66
|
+
function readJsonFile(filePath) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
69
|
+
return isObject(parsed) ? parsed : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function realpathMaybe(pathValue) {
|
|
76
|
+
try {
|
|
77
|
+
return realpathSync(pathValue);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return resolve(pathValue);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function samePhysicalPath(left, right) {
|
|
84
|
+
return realpathMaybe(left) === realpathMaybe(right);
|
|
85
|
+
}
|
|
86
|
+
function validHookSessionRecord(record) {
|
|
87
|
+
const trust = record.trust;
|
|
88
|
+
const strictProfile = record.strict_profile;
|
|
89
|
+
const expiresAt = typeof record.expires_at === "string" ? Date.parse(record.expires_at) : NaN;
|
|
90
|
+
return record.schema_version === 2
|
|
91
|
+
&& record.kind === "hook_active_session"
|
|
92
|
+
&& (trust === "audit-only" || trust === "trusted")
|
|
93
|
+
&& (strictProfile === "available" || strictProfile === "unavailable")
|
|
94
|
+
&& typeof record.change_id === "string"
|
|
95
|
+
&& record.change_id.length > 0
|
|
96
|
+
&& typeof record.repo_root === "string"
|
|
97
|
+
&& record.repo_root.length > 0
|
|
98
|
+
&& typeof record.session_id === "string"
|
|
99
|
+
&& record.session_id.length > 0
|
|
100
|
+
&& typeof record.workflow === "string"
|
|
101
|
+
&& record.workflow.length > 0
|
|
102
|
+
&& typeof record.started_at === "string"
|
|
103
|
+
&& Number.isFinite(Date.parse(record.started_at))
|
|
104
|
+
&& typeof record.expires_at === "string"
|
|
105
|
+
&& Number.isFinite(expiresAt)
|
|
106
|
+
&& typeof record.guard_version === "string"
|
|
107
|
+
&& typeof record.adapter_version === "string"
|
|
108
|
+
&& (record.hook_manifest_hash === null || typeof record.hook_manifest_hash === "string")
|
|
109
|
+
&& validReasonArray(record.audit_only_reasons);
|
|
110
|
+
}
|
|
111
|
+
function validReasonArray(value) {
|
|
112
|
+
return Array.isArray(value) && value.every((item) => (isObject(item)
|
|
113
|
+
&& typeof item.code === "string"
|
|
114
|
+
&& item.code.length > 0
|
|
115
|
+
&& typeof item.message === "string"
|
|
116
|
+
&& item.message.length > 0
|
|
117
|
+
&& Array.isArray(item.refs)
|
|
118
|
+
&& item.refs.every((ref) => typeof ref === "string")));
|
|
119
|
+
}
|
|
120
|
+
function corruptSessionReason(ctx, filePath) {
|
|
121
|
+
return reason("hook_session_corrupt", `active hook session record is corrupt: ${toPosix(relative(ctx.changeRoot, filePath))}`);
|
|
122
|
+
}
|
|
123
|
+
function strictUnavailableReasons(ctx, extra = []) {
|
|
124
|
+
const reasons = [];
|
|
125
|
+
if (!managedHooksManifestPresent(ctx.repoRoot)) {
|
|
126
|
+
reasons.push(reason("hook_manifest_missing_or_unmanaged", "managed .codex/hooks.json is missing or does not match the SuperSpec v2 adapter baseline"));
|
|
127
|
+
}
|
|
128
|
+
reasons.push(reason("r1_deny_spike_not_passed", "R-1 local spike did not prove project PreToolUse deny is active for this Codex invocation"));
|
|
129
|
+
reasons.push(reason("hook_provenance_unavailable", "no hook-only provenance mechanism is available in this environment; event JSON/stdin/env are replayable"));
|
|
130
|
+
reasons.push(...extra);
|
|
131
|
+
return reasons;
|
|
132
|
+
}
|
|
133
|
+
function strictProfile(ctx, extra = []) {
|
|
134
|
+
const auditOnly = strictUnavailableReasons(ctx, extra);
|
|
135
|
+
return {
|
|
136
|
+
strict_profile: "unavailable",
|
|
137
|
+
trust: "audit-only",
|
|
138
|
+
audit_only_reasons: auditOnly,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function hookDecision(ctx, gate, allowed, opts = {}) {
|
|
142
|
+
const strict = strictProfile(ctx, opts.audit_only_reasons ?? []);
|
|
143
|
+
const normalized = opts.event ? normalizeHookEvent(opts.event) : null;
|
|
144
|
+
return {
|
|
145
|
+
allowed,
|
|
146
|
+
decision: allowed ? "allow" : "block",
|
|
147
|
+
change_id: ctx.change,
|
|
148
|
+
gate,
|
|
149
|
+
strict_profile: strict.strict_profile,
|
|
150
|
+
enforcement: opts.enforcement ?? (allowed ? "pass-through" : "deny"),
|
|
151
|
+
trust: strict.trust,
|
|
152
|
+
block_reasons: opts.block_reasons ?? [],
|
|
153
|
+
audit_only_reasons: strict.audit_only_reasons,
|
|
154
|
+
target_paths: opts.target_paths,
|
|
155
|
+
tool_name: normalized?.tool_name,
|
|
156
|
+
hook_event_name: normalized?.hook_event_name,
|
|
157
|
+
hook_event_id: normalized?.hook_event_id,
|
|
158
|
+
actions: opts.actions,
|
|
159
|
+
next_allowed_actions: opts.next_allowed_actions ?? [],
|
|
160
|
+
trust_warnings: trustWarnings(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function stateCorruptDecision(ctx, gate) {
|
|
164
|
+
if (ctx.stateCorruptReasons.length === 0)
|
|
165
|
+
return null;
|
|
166
|
+
return hookDecision(ctx, gate, false, {
|
|
167
|
+
enforcement: "deny",
|
|
168
|
+
block_reasons: ctx.stateCorruptReasons,
|
|
169
|
+
audit_only_reasons: ctx.stateCorruptReasons,
|
|
170
|
+
next_allowed_actions: ["inspect the corrupt .superspec/superspec-state.json, then rerun recompute --rebuild-corrupt"],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function statusDecision(ctx, gate, opts = {}) {
|
|
174
|
+
const strict = strictProfile(ctx, opts.audit_only_reasons ?? []);
|
|
175
|
+
const allowed = opts.allowed ?? true;
|
|
176
|
+
return {
|
|
177
|
+
allowed,
|
|
178
|
+
decision: "status",
|
|
179
|
+
change_id: ctx.change,
|
|
180
|
+
gate,
|
|
181
|
+
strict_profile: strict.strict_profile,
|
|
182
|
+
enforcement: "audit-only",
|
|
183
|
+
trust: strict.trust,
|
|
184
|
+
block_reasons: [],
|
|
185
|
+
audit_only_reasons: strict.audit_only_reasons,
|
|
186
|
+
actions: opts.actions,
|
|
187
|
+
next_allowed_actions: [],
|
|
188
|
+
trust_warnings: trustWarnings(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function activeSession(ctx, event) {
|
|
192
|
+
const sessionId = event && typeof event.session_id === "string" && event.session_id ? event.session_id : "unknown-session";
|
|
193
|
+
const dir = activeSessionsDir(ctx.changeRoot);
|
|
194
|
+
if (!existsSync(dir)) {
|
|
195
|
+
const missing = reason("hook_session_missing", "no active SuperSpec hook session for this Codex session");
|
|
196
|
+
if (!existsSync(hookRuntimeDir(ctx.changeRoot))) {
|
|
197
|
+
return {
|
|
198
|
+
record: null,
|
|
199
|
+
enforcement_active: false,
|
|
200
|
+
audit_only_reasons: [missing],
|
|
201
|
+
block_reasons: [],
|
|
202
|
+
corrupt_paths: [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
record: null,
|
|
207
|
+
enforcement_active: false,
|
|
208
|
+
audit_only_reasons: [missing],
|
|
209
|
+
block_reasons: [missing],
|
|
210
|
+
corrupt_paths: [],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const files = readdirSync(dir)
|
|
214
|
+
.filter((name) => name.endsWith(".json"))
|
|
215
|
+
.map((name) => join(dir, name));
|
|
216
|
+
if (files.length === 0) {
|
|
217
|
+
const missing = reason("hook_session_missing", "no active SuperSpec hook session for this Codex session");
|
|
218
|
+
return {
|
|
219
|
+
record: null,
|
|
220
|
+
enforcement_active: false,
|
|
221
|
+
audit_only_reasons: [missing],
|
|
222
|
+
block_reasons: [missing],
|
|
223
|
+
corrupt_paths: [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const auditOnlyReasons = [];
|
|
227
|
+
const blockReasons = [];
|
|
228
|
+
const corruptPaths = [];
|
|
229
|
+
let matchingRecord = null;
|
|
230
|
+
let sawMatchingChange = false;
|
|
231
|
+
for (const filePath of files) {
|
|
232
|
+
const parsed = readJsonFile(filePath);
|
|
233
|
+
if (!parsed || !validHookSessionRecord(parsed)) {
|
|
234
|
+
blockReasons.push(corruptSessionReason(ctx, filePath));
|
|
235
|
+
corruptPaths.push(filePath);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const record = parsed;
|
|
239
|
+
if (record.change_id !== ctx.change) {
|
|
240
|
+
blockReasons.push(reason("hook_session_change_mismatch", "active hook session change_id does not match request"));
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!samePhysicalPath(record.repo_root, ctx.repoRoot)) {
|
|
244
|
+
blockReasons.push(reason("hook_session_repo_mismatch", "active hook session repo_root does not match request"));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
sawMatchingChange = true;
|
|
248
|
+
if (record.session_id !== sessionId) {
|
|
249
|
+
auditOnlyReasons.push(reason("hook_session_session_mismatch", "active hook session_id does not match hook event; audit-only enforcement uses repo/change lease fallback"));
|
|
250
|
+
}
|
|
251
|
+
if (Date.parse(record.expires_at) <= Date.now()) {
|
|
252
|
+
blockReasons.push(reason("hook_session_expired", "active hook session lease expired"));
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (record.trust !== "trusted") {
|
|
256
|
+
auditOnlyReasons.push(reason("hook_session_audit_only", "active hook session exists only as audit-only because lifecycle provenance is unavailable"));
|
|
257
|
+
}
|
|
258
|
+
matchingRecord ??= record;
|
|
259
|
+
}
|
|
260
|
+
if (!sawMatchingChange && blockReasons.length === 0) {
|
|
261
|
+
const missing = reason("hook_session_missing", "no active SuperSpec hook session for this Codex session");
|
|
262
|
+
auditOnlyReasons.push(missing);
|
|
263
|
+
blockReasons.push(missing);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
record: matchingRecord,
|
|
267
|
+
enforcement_active: matchingRecord !== null || blockReasons.length > 0,
|
|
268
|
+
audit_only_reasons: auditOnlyReasons,
|
|
269
|
+
block_reasons: blockReasons,
|
|
270
|
+
corrupt_paths: corruptPaths,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function changeRelRoot(ctx) {
|
|
274
|
+
return cleanRelPath(relative(ctx.repoRoot, ctx.changeRoot));
|
|
275
|
+
}
|
|
276
|
+
function isProtectedTrustRoot(ctx, pathValue) {
|
|
277
|
+
return isSuperSpecTrustRootPath(pathValue, { changeRootRel: changeRelRoot(ctx) });
|
|
278
|
+
}
|
|
279
|
+
function isOpenSpecTasks(ctx, pathValue) {
|
|
280
|
+
return cleanRelPath(pathValue) === `${changeRelRoot(ctx)}/tasks.md`;
|
|
281
|
+
}
|
|
282
|
+
function isOpenSpecCanonical(ctx, pathValue) {
|
|
283
|
+
const rel = cleanRelPath(pathValue);
|
|
284
|
+
const changeRoot = changeRelRoot(ctx);
|
|
285
|
+
return rel === `${changeRoot}/proposal.md`
|
|
286
|
+
|| rel === `${changeRoot}/design.md`
|
|
287
|
+
|| rel === `${changeRoot}/tasks.md`
|
|
288
|
+
|| rel.startsWith(`${changeRoot}/specs/`);
|
|
289
|
+
}
|
|
290
|
+
function matchingTaskIds(ctx, paths) {
|
|
291
|
+
const tasks = parse_tasks(ctx.changeRoot);
|
|
292
|
+
const ids = [];
|
|
293
|
+
for (const [taskId, task] of Object.entries(tasks)) {
|
|
294
|
+
const scopes = splitList(task.attrs.write_scope ?? "");
|
|
295
|
+
if (scopes.length > 0 && scopes.some((scope) => anyPathInScope(paths, scope)))
|
|
296
|
+
ids.push(taskId);
|
|
297
|
+
}
|
|
298
|
+
return ids.sort();
|
|
299
|
+
}
|
|
300
|
+
function decisionReasons(decision) {
|
|
301
|
+
return Array.isArray(decision.block_reasons) ? decision.block_reasons : [];
|
|
302
|
+
}
|
|
303
|
+
function evaluateTaskEdit(ctx, taskId) {
|
|
304
|
+
return check_task_edit(ctx.change, ctx.status, ctx.changeRoot, ctx.evidences, taskId);
|
|
305
|
+
}
|
|
306
|
+
function evaluateTaskComplete(ctx, taskId) {
|
|
307
|
+
return check_task_complete(ctx.change, ctx.status, ctx.changeRoot, ctx.evidences, taskId);
|
|
308
|
+
}
|
|
309
|
+
function evaluateTaskReopen(ctx, taskId) {
|
|
310
|
+
return check_task_reopen(ctx.change, ctx.status, ctx.changeRoot, ctx.evidences, taskId);
|
|
311
|
+
}
|
|
312
|
+
function evaluateArchiveReady(ctx) {
|
|
313
|
+
return check_archive_ready(ctx.change, ctx.status, ctx.changeRoot, ctx.evidences);
|
|
314
|
+
}
|
|
315
|
+
function evaluateTerminalAuthority(ctx, endReason) {
|
|
316
|
+
if (endReason === "completed") {
|
|
317
|
+
const review = check_review_complete(ctx.change, ctx.status, ctx.changeRoot, ctx.evidences);
|
|
318
|
+
return review.allowed ? [] : [
|
|
319
|
+
reason("hook_session_terminal_not_ready", "hook-session-end reason completed requires review_complete to pass"),
|
|
320
|
+
...decisionReasons(review),
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
if (endReason === "archived") {
|
|
324
|
+
const archived = check_archived(ctx.change, ctx.repoRoot);
|
|
325
|
+
return archived.allowed ? [] : [
|
|
326
|
+
reason("hook_session_terminal_not_ready", "hook-session-end reason archived requires check-archived to pass"),
|
|
327
|
+
...decisionReasons(archived),
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
if (endReason === "cancelled" || endReason === "abandoned") {
|
|
331
|
+
return [
|
|
332
|
+
reason("hook_session_terminal_authority_unavailable", `hook-session-end reason ${endReason} requires trusted terminal provenance before it can close an audit lease`),
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
return [reason("hook_session_terminal_authority_unavailable", `hook-session-end reason ${endReason} has no audit-only terminal authority in this environment`)];
|
|
336
|
+
}
|
|
337
|
+
function sessionSummary(record) {
|
|
338
|
+
if (!record)
|
|
339
|
+
return null;
|
|
340
|
+
return {
|
|
341
|
+
schema_version: record.schema_version,
|
|
342
|
+
kind: record.kind,
|
|
343
|
+
trust: record.trust,
|
|
344
|
+
change_id: record.change_id,
|
|
345
|
+
repo_root: record.repo_root,
|
|
346
|
+
session_id: record.session_id,
|
|
347
|
+
workflow: record.workflow,
|
|
348
|
+
started_at: record.started_at,
|
|
349
|
+
expires_at: record.expires_at,
|
|
350
|
+
guard_version: record.guard_version,
|
|
351
|
+
adapter_version: record.adapter_version,
|
|
352
|
+
hook_manifest_hash: record.hook_manifest_hash,
|
|
353
|
+
strict_profile: record.strict_profile,
|
|
354
|
+
audit_only_reasons: record.audit_only_reasons,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function removeEmptyDir(path) {
|
|
358
|
+
try {
|
|
359
|
+
if (existsSync(path) && readdirSync(path).length === 0)
|
|
360
|
+
rmdirSync(path);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// Best effort cleanup only; a non-empty or concurrently removed directory is not an end failure.
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function eventRefToDecision(command, change, eventRef, fn) {
|
|
367
|
+
const ctx = loadHookContext(change);
|
|
368
|
+
const corrupt = stateCorruptDecision(ctx, command);
|
|
369
|
+
if (corrupt)
|
|
370
|
+
return corrupt;
|
|
371
|
+
try {
|
|
372
|
+
const event = readHookEventRef(eventRef);
|
|
373
|
+
return fn(ctx, event);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
const message = err instanceof GuardError ? err.message : `${err.name}: ${err.message}`;
|
|
377
|
+
return hookDecision(ctx, command, false, {
|
|
378
|
+
enforcement: "deny",
|
|
379
|
+
block_reasons: [reason("hook_event_invalid", message)],
|
|
380
|
+
next_allowed_actions: ["repair the hook event payload and rerun the hook check"],
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
export function hookCheckWrite(change, eventRef) {
|
|
385
|
+
return eventRefToDecision("hook_check_write", change, eventRef, (ctx, event) => {
|
|
386
|
+
const intent = extractWriteIntent(event, ctx.repoRoot);
|
|
387
|
+
const active = activeSession(ctx, event);
|
|
388
|
+
const activeEnoughForAuditEnforcement = active.enforcement_active;
|
|
389
|
+
const blockReasons = [];
|
|
390
|
+
const auditOnlyReasons = [...active.audit_only_reasons, ...intent.reasons];
|
|
391
|
+
const toolName = typeof event.tool_name === "string" ? event.tool_name : "";
|
|
392
|
+
if (intent.internal_hook_writer_command) {
|
|
393
|
+
blockReasons.push(reason("hook_internal_writer_invocation", "model-controlled tool calls may not invoke SuperSpec hook writer commands"));
|
|
394
|
+
}
|
|
395
|
+
if (intent.unsafe_lifecycle_termination_command) {
|
|
396
|
+
blockReasons.push(reason("hook_session_termination_untrusted", "model-controlled tool calls may not terminate SuperSpec hook sessions without trusted terminal authority"));
|
|
397
|
+
}
|
|
398
|
+
const protectedPaths = intent.target_paths.filter((path) => isProtectedTrustRoot(ctx, path));
|
|
399
|
+
if (protectedPaths.length > 0) {
|
|
400
|
+
blockReasons.push(reason("protected_trust_root_write", `direct writes to SuperSpec trust roots are denied: ${renderReasons(protectedPaths.map((path) => reason(path, path)))}`, protectedPaths));
|
|
401
|
+
}
|
|
402
|
+
if (intent.shell_write_command && intent.trust_root_text_matches.length > 0) {
|
|
403
|
+
blockReasons.push(reason("protected_trust_root_write", `pathless write-capable command appears to touch SuperSpec trust roots: ${intent.trust_root_text_matches.join(", ")}`, intent.trust_root_text_matches));
|
|
404
|
+
}
|
|
405
|
+
if (intent.archive_command) {
|
|
406
|
+
const archive = evaluateArchiveReady(ctx);
|
|
407
|
+
if (!archive.allowed) {
|
|
408
|
+
blockReasons.push(reason("archive_ready_missing", "openspec archive requires a fresh archive_ready allow decision"));
|
|
409
|
+
blockReasons.push(...decisionReasons(archive));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const scopedTaskIds = matchingTaskIds(ctx, intent.target_paths);
|
|
413
|
+
const sessionSensitiveWrite = scopedTaskIds.length > 0
|
|
414
|
+
|| protectedPaths.length > 0
|
|
415
|
+
|| intent.reasons.some((item) => item.code === "target_path_outside_repo")
|
|
416
|
+
|| intent.reasons.some((item) => item.code === "protected_trust_root_link_source")
|
|
417
|
+
|| intent.reasons.some((item) => item.code === "shell_path_may_touch_trust_root")
|
|
418
|
+
|| intent.reasons.some((item) => item.code === "curl_config_write_target_unknown")
|
|
419
|
+
|| intent.reasons.some((item) => item.code === "curl_write_target_unknown")
|
|
420
|
+
|| intent.archive_command
|
|
421
|
+
|| intent.internal_hook_writer_command
|
|
422
|
+
|| intent.unsafe_lifecycle_termination_command
|
|
423
|
+
|| (intent.shell_write_command && intent.trust_root_text_matches.length > 0)
|
|
424
|
+
|| intent.target_paths.some((path) => isOpenSpecTasks(ctx, path))
|
|
425
|
+
|| intent.target_paths.some((path) => isOpenSpecCanonical(ctx, path))
|
|
426
|
+
|| (intent.shell_write_command && intent.target_paths.length === 0);
|
|
427
|
+
const writeCapableEvent = ["apply_patch", "Edit", "Write"].includes(toolName)
|
|
428
|
+
|| intent.target_paths.length > 0
|
|
429
|
+
|| intent.shell_write_command
|
|
430
|
+
|| intent.unsupported_write_surface
|
|
431
|
+
|| intent.reasons.length > 0
|
|
432
|
+
|| intent.archive_command
|
|
433
|
+
|| intent.internal_hook_writer_command
|
|
434
|
+
|| intent.unsafe_lifecycle_termination_command;
|
|
435
|
+
const corruptActiveSessionForWrite = active.corrupt_paths.length > 0 && writeCapableEvent;
|
|
436
|
+
if (corruptActiveSessionForWrite) {
|
|
437
|
+
blockReasons.push(...active.block_reasons);
|
|
438
|
+
}
|
|
439
|
+
const missingOrInvalidActiveSessionForSensitiveWrite = active.block_reasons.length > 0 && sessionSensitiveWrite && !corruptActiveSessionForWrite;
|
|
440
|
+
if (missingOrInvalidActiveSessionForSensitiveWrite) {
|
|
441
|
+
blockReasons.push(...active.block_reasons);
|
|
442
|
+
}
|
|
443
|
+
const failClosedIntentReasons = intent.reasons.filter((item) => (item.code === "unknown_patch_shape"
|
|
444
|
+
|| item.code === "target_path_outside_repo"
|
|
445
|
+
|| item.code === "protected_trust_root_link_source"
|
|
446
|
+
|| item.code === "shell_path_may_touch_trust_root"
|
|
447
|
+
|| item.code === "curl_config_write_target_unknown"
|
|
448
|
+
|| item.code === "curl_write_target_unknown"));
|
|
449
|
+
if (failClosedIntentReasons.length > 0) {
|
|
450
|
+
blockReasons.push(...failClosedIntentReasons);
|
|
451
|
+
}
|
|
452
|
+
if (!activeEnoughForAuditEnforcement && !missingOrInvalidActiveSessionForSensitiveWrite) {
|
|
453
|
+
if (blockReasons.length > 0) {
|
|
454
|
+
return hookDecision(ctx, "hook_check_write", false, {
|
|
455
|
+
event,
|
|
456
|
+
enforcement: "deny",
|
|
457
|
+
block_reasons: blockReasons,
|
|
458
|
+
audit_only_reasons: auditOnlyReasons,
|
|
459
|
+
target_paths: intent.target_paths,
|
|
460
|
+
next_allowed_actions: ["rerun the relevant superspec guard check and use the expected workflow command surface"],
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return hookDecision(ctx, "hook_check_write", true, {
|
|
464
|
+
event,
|
|
465
|
+
enforcement: "pass-through",
|
|
466
|
+
audit_only_reasons: auditOnlyReasons,
|
|
467
|
+
target_paths: intent.target_paths,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (activeEnoughForAuditEnforcement || missingOrInvalidActiveSessionForSensitiveWrite) {
|
|
471
|
+
blockReasons.push(...intent.reasons);
|
|
472
|
+
}
|
|
473
|
+
for (const taskId of scopedTaskIds) {
|
|
474
|
+
const taskDecision = evaluateTaskEdit(ctx, taskId);
|
|
475
|
+
if (!taskDecision.allowed) {
|
|
476
|
+
blockReasons.push(reason("task_edit_missing", `write_scope edit for ${taskId} requires task_edit allow`, [taskId]));
|
|
477
|
+
blockReasons.push(...decisionReasons(taskDecision));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (intent.target_paths.some((path) => isOpenSpecTasks(ctx, path))) {
|
|
481
|
+
for (const taskId of intent.task_checkbox_completions) {
|
|
482
|
+
const taskDecision = evaluateTaskComplete(ctx, taskId);
|
|
483
|
+
if (!taskDecision.allowed) {
|
|
484
|
+
blockReasons.push(reason("task_complete_missing", `checking ${taskId} requires task_complete allow`, [taskId]));
|
|
485
|
+
blockReasons.push(...decisionReasons(taskDecision));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for (const taskId of intent.task_checkbox_reopens) {
|
|
489
|
+
const taskDecision = evaluateTaskReopen(ctx, taskId);
|
|
490
|
+
if (!taskDecision.allowed) {
|
|
491
|
+
blockReasons.push(reason("task_reopen_missing", `reopening ${taskId} requires task_reopen allow`, [taskId]));
|
|
492
|
+
blockReasons.push(...decisionReasons(taskDecision));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (intent.task_checkbox_completions.length === 0 && intent.task_checkbox_reopens.length === 0) {
|
|
496
|
+
blockReasons.push(reason("openspec_tasks_direct_edit", "tasks.md edits outside guarded checkbox transitions are denied during an active SuperSpec session"));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const canonical = intent.target_paths.filter((path) => isOpenSpecCanonical(ctx, path) && !isOpenSpecTasks(ctx, path));
|
|
500
|
+
if (canonical.length > 0 && activeEnoughForAuditEnforcement) {
|
|
501
|
+
blockReasons.push(reason("openspec_canonical_direct_edit", `OpenSpec canonical artifacts require the workflow instruction surface: ${canonical.join(", ")}`, canonical));
|
|
502
|
+
}
|
|
503
|
+
if (intent.shell_write_command && intent.target_paths.length === 0) {
|
|
504
|
+
blockReasons.push(reason("bash_write_target_unknown", "Bash command appears write-capable but target paths could not be classified"));
|
|
505
|
+
}
|
|
506
|
+
if (blockReasons.length > 0) {
|
|
507
|
+
return hookDecision(ctx, "hook_check_write", false, {
|
|
508
|
+
event,
|
|
509
|
+
enforcement: "deny",
|
|
510
|
+
block_reasons: blockReasons,
|
|
511
|
+
audit_only_reasons: auditOnlyReasons,
|
|
512
|
+
target_paths: intent.target_paths,
|
|
513
|
+
next_allowed_actions: ["rerun the relevant superspec guard check and use the expected workflow command surface"],
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return hookDecision(ctx, "hook_check_write", true, {
|
|
517
|
+
event,
|
|
518
|
+
enforcement: activeEnoughForAuditEnforcement ? "guarded" : "pass-through",
|
|
519
|
+
audit_only_reasons: auditOnlyReasons,
|
|
520
|
+
target_paths: intent.target_paths,
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
export function hookCheckCommand(change, eventRef) {
|
|
525
|
+
return hookCheckWrite(change, eventRef);
|
|
526
|
+
}
|
|
527
|
+
export function hookHealth(change) {
|
|
528
|
+
const ctx = loadHookContext(change);
|
|
529
|
+
const corrupt = stateCorruptDecision(ctx, "hook_health");
|
|
530
|
+
if (corrupt)
|
|
531
|
+
return corrupt;
|
|
532
|
+
const strict = strictProfile(ctx);
|
|
533
|
+
return statusDecision(ctx, "hook_health", {
|
|
534
|
+
allowed: false,
|
|
535
|
+
actions: [{
|
|
536
|
+
strict_profile: strict.strict_profile,
|
|
537
|
+
trust: strict.trust,
|
|
538
|
+
mechanical: false,
|
|
539
|
+
runtime_verified: false,
|
|
540
|
+
hook_manifest_hash: hookManifestHash(ctx.repoRoot),
|
|
541
|
+
managed_hooks_manifest_present: managedHooksManifestPresent(ctx.repoRoot),
|
|
542
|
+
openspec_status_summary: artifact_status_map(ctx.status),
|
|
543
|
+
audit_only_reasons: strict.audit_only_reasons,
|
|
544
|
+
}],
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
export function hookSessionBegin(change, workflow, _entrypointToken) {
|
|
548
|
+
const ctx = loadHookContext(change);
|
|
549
|
+
const corrupt = stateCorruptDecision(ctx, "hook_session_begin");
|
|
550
|
+
if (corrupt)
|
|
551
|
+
return corrupt;
|
|
552
|
+
const sessionId = process.env.CODEX_SESSION_ID || process.env.SUPERSPEC_HOOK_SESSION_ID || "manual-cli-session";
|
|
553
|
+
const auditOnlyReasons = [
|
|
554
|
+
reason("workflow_entrypoint_provenance_unavailable", "hook-session-begin was invoked through ordinary CLI authority; recording audit-only lease only"),
|
|
555
|
+
];
|
|
556
|
+
const strict = strictProfile(ctx, auditOnlyReasons);
|
|
557
|
+
const record = {
|
|
558
|
+
schema_version: 2,
|
|
559
|
+
kind: "hook_active_session",
|
|
560
|
+
trust: strict.trust,
|
|
561
|
+
change_id: change,
|
|
562
|
+
repo_root: ctx.repoRoot,
|
|
563
|
+
session_id: sessionId,
|
|
564
|
+
workflow,
|
|
565
|
+
started_at: nowIso(),
|
|
566
|
+
expires_at: expiresIso(),
|
|
567
|
+
guard_version: GUARD_VERSION,
|
|
568
|
+
adapter_version: HOOK_ADAPTER_VERSION,
|
|
569
|
+
hook_manifest_hash: hookManifestHash(ctx.repoRoot),
|
|
570
|
+
strict_profile: strict.strict_profile,
|
|
571
|
+
audit_only_reasons: strict.audit_only_reasons,
|
|
572
|
+
};
|
|
573
|
+
with_state_lock(ctx.changeRoot, () => {
|
|
574
|
+
const target = sessionFile(ctx.changeRoot, sessionId);
|
|
575
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
576
|
+
writeFileSync(target, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
577
|
+
});
|
|
578
|
+
return statusDecision(ctx, "hook_session_begin", {
|
|
579
|
+
audit_only_reasons: auditOnlyReasons,
|
|
580
|
+
actions: [{ session: sessionSummary(record), audit_only_session_created: true, trusted_active_session_created: false }],
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
export function hookSessionStatus(change) {
|
|
584
|
+
const ctx = loadHookContext(change);
|
|
585
|
+
const corrupt = stateCorruptDecision(ctx, "hook_session_status");
|
|
586
|
+
if (corrupt)
|
|
587
|
+
return corrupt;
|
|
588
|
+
const sessionId = process.env.CODEX_SESSION_ID || process.env.SUPERSPEC_HOOK_SESSION_ID || "manual-cli-session";
|
|
589
|
+
const active = activeSession(ctx, { session_id: sessionId });
|
|
590
|
+
return statusDecision(ctx, "hook_session_status", {
|
|
591
|
+
audit_only_reasons: [...active.audit_only_reasons, ...active.block_reasons],
|
|
592
|
+
actions: [{
|
|
593
|
+
active: active.enforcement_active,
|
|
594
|
+
session_id: sessionId,
|
|
595
|
+
session: sessionSummary(active.record),
|
|
596
|
+
diagnostics: [...active.audit_only_reasons, ...active.block_reasons],
|
|
597
|
+
corrupt_paths: active.corrupt_paths.map((path) => toPosix(relative(ctx.changeRoot, path))),
|
|
598
|
+
}],
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
export function hookSessionEnd(change, endReason, _lifecycleToken) {
|
|
602
|
+
const ctx = endReason === "archived" ? loadArchivedHookContext(change) : loadHookContext(change);
|
|
603
|
+
const corrupt = stateCorruptDecision(ctx, "hook_session_end");
|
|
604
|
+
if (corrupt)
|
|
605
|
+
return corrupt;
|
|
606
|
+
const sessionId = process.env.CODEX_SESSION_ID || process.env.SUPERSPEC_HOOK_SESSION_ID || "manual-cli-session";
|
|
607
|
+
const allowedReasons = new Set(["completed", "cancelled", "archived", "abandoned"]);
|
|
608
|
+
const auditOnlyReasons = [
|
|
609
|
+
reason("workflow_terminal_authority_unavailable", "hook-session-end was invoked through ordinary CLI authority; trusted sessions cannot be closed this way"),
|
|
610
|
+
];
|
|
611
|
+
const active = activeSession(ctx, { session_id: sessionId });
|
|
612
|
+
let auditOnlySessionClosed = false;
|
|
613
|
+
with_state_lock(ctx.changeRoot, () => {
|
|
614
|
+
const target = sessionFile(ctx.changeRoot, sessionId);
|
|
615
|
+
if (!existsSync(target))
|
|
616
|
+
return;
|
|
617
|
+
const parsed = readJsonFile(target);
|
|
618
|
+
if (!parsed || !validHookSessionRecord(parsed)) {
|
|
619
|
+
auditOnlyReasons.push(reason("hook_session_corrupt", "active hook session record is corrupt and was not closed"));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const record = parsed;
|
|
623
|
+
if (record.change_id !== change || !samePhysicalPath(record.repo_root, ctx.repoRoot) || record.session_id !== sessionId) {
|
|
624
|
+
auditOnlyReasons.push(reason("hook_session_identity_mismatch", "active hook session identity does not match the terminal request"));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (Date.parse(record.expires_at) <= Date.now()) {
|
|
628
|
+
auditOnlyReasons.push(reason("hook_session_expired", "active hook session lease expired and was not closed"));
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (!allowedReasons.has(endReason)) {
|
|
632
|
+
auditOnlyReasons.push(reason("hook_session_end_reason_invalid", `unsupported hook session end reason: ${endReason}`));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (record.trust === "trusted") {
|
|
636
|
+
auditOnlyReasons.push(reason("hook_session_trusted_close_unavailable", "ordinary CLI hook-session-end authority cannot close a trusted active session"));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const terminalReasons = evaluateTerminalAuthority(ctx, endReason);
|
|
640
|
+
if (terminalReasons.length > 0) {
|
|
641
|
+
auditOnlyReasons.push(...terminalReasons);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
rmSync(target, { force: true });
|
|
645
|
+
removeEmptyDir(activeSessionsDir(ctx.changeRoot));
|
|
646
|
+
removeEmptyDir(hookRuntimeDir(ctx.changeRoot));
|
|
647
|
+
auditOnlySessionClosed = true;
|
|
648
|
+
});
|
|
649
|
+
return statusDecision(ctx, "hook_session_end", {
|
|
650
|
+
audit_only_reasons: auditOnlyReasons,
|
|
651
|
+
actions: [{
|
|
652
|
+
trusted_active_session_closed: false,
|
|
653
|
+
audit_only_session_closed: auditOnlySessionClosed,
|
|
654
|
+
active_session_present: active.enforcement_active,
|
|
655
|
+
reason: endReason,
|
|
656
|
+
}],
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
function provenanceFor(ctx, event) {
|
|
660
|
+
const normalized = normalizeHookEvent(event);
|
|
661
|
+
return {
|
|
662
|
+
hook_event_name: normalized.hook_event_name,
|
|
663
|
+
session_id: normalized.session_id,
|
|
664
|
+
turn_id: typeof event.turn_id === "string" ? event.turn_id : null,
|
|
665
|
+
tool_use_id: typeof event.tool_use_id === "string" ? event.tool_use_id : null,
|
|
666
|
+
agent_id: typeof event.agent_id === "string" ? event.agent_id : null,
|
|
667
|
+
adapter_version: HOOK_ADAPTER_VERSION,
|
|
668
|
+
hook_manifest_hash: hookManifestHash(ctx.repoRoot),
|
|
669
|
+
provenance_mechanism_id: "unavailable:r1-spike-failed",
|
|
670
|
+
validation: "audit-only",
|
|
671
|
+
event_content_hash: eventContentHash(event),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
function materializeRawLog(ctx, event, prefix) {
|
|
675
|
+
const normalized = normalizeHookEvent(event);
|
|
676
|
+
const id = normalized.hook_event_id.slice("sha256:".length, "sha256:".length + 16);
|
|
677
|
+
const rel = `.superspec/raw/${prefix}-${id}.log`;
|
|
678
|
+
const target = join(ctx.changeRoot, rel);
|
|
679
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
680
|
+
writeFileSync(target, `${JSON.stringify(event, null, 2)}\n`, "utf8");
|
|
681
|
+
return { rel, ref: pinnedFileRef(ctx.changeRoot, rel) };
|
|
682
|
+
}
|
|
683
|
+
function writeAuditEvidence(ctx, evidence) {
|
|
684
|
+
const target = join(hookAuditDir(ctx.changeRoot), `${String(evidence.evidence_id)}.json`);
|
|
685
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
686
|
+
writeFileSync(target, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
|
|
687
|
+
return toPosix(relative(ctx.changeRoot, target));
|
|
688
|
+
}
|
|
689
|
+
export function hookRecordTest(change, eventRef) {
|
|
690
|
+
return eventRefToDecision("hook_record_test", change, eventRef, (ctx, event) => {
|
|
691
|
+
const normalized = normalizeHookEvent(event);
|
|
692
|
+
const toolInput = isObject(event.tool_input) ? event.tool_input : {};
|
|
693
|
+
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
694
|
+
const toolResponse = isObject(event.tool_response) ? event.tool_response : {};
|
|
695
|
+
const exitCode = typeof toolResponse.exit_code === "number"
|
|
696
|
+
? toolResponse.exit_code
|
|
697
|
+
: (typeof toolResponse.exitCode === "number" ? toolResponse.exitCode : null);
|
|
698
|
+
const evidenceId = `EV-hook-test-${normalized.hook_event_id.slice("sha256:".length, "sha256:".length + 16)}`;
|
|
699
|
+
let rel = "";
|
|
700
|
+
with_state_lock(ctx.changeRoot, () => {
|
|
701
|
+
const raw = materializeRawLog(ctx, event, "hook-test");
|
|
702
|
+
const evidence = {
|
|
703
|
+
schema_version: 1,
|
|
704
|
+
evidence_id: evidenceId,
|
|
705
|
+
change_id: change,
|
|
706
|
+
gate: "hook_record_test",
|
|
707
|
+
kind: "test_run",
|
|
708
|
+
created_at: nowIso(),
|
|
709
|
+
created_by: "superspec-hook",
|
|
710
|
+
status: "blocked",
|
|
711
|
+
trust: "audit-only",
|
|
712
|
+
semantic_status: exitCode === 0 ? "expected_success" : "expected_failure",
|
|
713
|
+
test_command: command,
|
|
714
|
+
result_summary: "audit-only hook telemetry; strict runtime evidence disabled because hook provenance is unavailable",
|
|
715
|
+
raw_log_refs: [raw.rel],
|
|
716
|
+
raw_log_pinned_refs: raw.ref ? [raw.ref] : [],
|
|
717
|
+
hook_event_id: normalized.hook_event_id,
|
|
718
|
+
exit_code: exitCode,
|
|
719
|
+
command_fingerprint: sha256_text(command),
|
|
720
|
+
hook_provenance: provenanceFor(ctx, event),
|
|
721
|
+
};
|
|
722
|
+
rel = writeAuditEvidence(ctx, evidence);
|
|
723
|
+
});
|
|
724
|
+
return statusDecision(ctx, "hook_record_test", {
|
|
725
|
+
actions: [{ trusted_runtime_evidence_written: false, audit_only_evidence_ref: rel, evidence_id: evidenceId }],
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
function writeRunlogRecord(ctx, record) {
|
|
730
|
+
const target = runlogPath(ctx.changeRoot);
|
|
731
|
+
with_state_lock(ctx.changeRoot, () => {
|
|
732
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
733
|
+
appendFileSync(target, `${JSON.stringify(record)}\n`, "utf8");
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
export function hookRecordSubagentStart(change, eventRef) {
|
|
737
|
+
return eventRefToDecision("hook_record_subagent_start", change, eventRef, (ctx, event) => {
|
|
738
|
+
const normalized = normalizeHookEvent(event);
|
|
739
|
+
const record = {
|
|
740
|
+
schema_version: 2,
|
|
741
|
+
kind: "subagent_start",
|
|
742
|
+
trust: "audit-only",
|
|
743
|
+
hook_event_id: normalized.hook_event_id,
|
|
744
|
+
hook_event_name: normalized.hook_event_name,
|
|
745
|
+
hook_provenance: provenanceFor(ctx, event),
|
|
746
|
+
session_id: normalized.session_id,
|
|
747
|
+
turn_id: typeof event.turn_id === "string" ? event.turn_id : undefined,
|
|
748
|
+
run_id: typeof event.run_id === "string" ? event.run_id : String(event.agent_id ?? normalized.hook_event_id),
|
|
749
|
+
agent_id: String(event.agent_id ?? ""),
|
|
750
|
+
agent_type: String(event.agent_type ?? ""),
|
|
751
|
+
cwd: typeof event.cwd === "string" ? event.cwd : undefined,
|
|
752
|
+
prompt_hash: typeof event.prompt === "string" ? sha256_text(event.prompt) : undefined,
|
|
753
|
+
prompt_ref: typeof event.prompt_ref === "string" ? event.prompt_ref : undefined,
|
|
754
|
+
started_at: nowIso(),
|
|
755
|
+
};
|
|
756
|
+
writeRunlogRecord(ctx, record);
|
|
757
|
+
return statusDecision(ctx, "hook_record_subagent_start", {
|
|
758
|
+
actions: [{ trusted_runlog_record_written: false, audit_only_runlog: toPosix(relative(ctx.changeRoot, runlogPath(ctx.changeRoot))) }],
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
export function hookRecordSubagentStop(change, eventRef) {
|
|
763
|
+
return eventRefToDecision("hook_record_subagent_stop", change, eventRef, (ctx, event) => {
|
|
764
|
+
const normalized = normalizeHookEvent(event);
|
|
765
|
+
const output = typeof event.last_assistant_message === "string" ? event.last_assistant_message : "";
|
|
766
|
+
const record = {
|
|
767
|
+
schema_version: 2,
|
|
768
|
+
kind: "subagent_stop",
|
|
769
|
+
trust: "audit-only",
|
|
770
|
+
hook_event_id: normalized.hook_event_id,
|
|
771
|
+
hook_event_name: normalized.hook_event_name,
|
|
772
|
+
hook_provenance: provenanceFor(ctx, event),
|
|
773
|
+
session_id: normalized.session_id,
|
|
774
|
+
turn_id: typeof event.turn_id === "string" ? event.turn_id : undefined,
|
|
775
|
+
run_id: typeof event.run_id === "string" ? event.run_id : String(event.agent_id ?? normalized.hook_event_id),
|
|
776
|
+
agent_id: String(event.agent_id ?? ""),
|
|
777
|
+
agent_type: String(event.agent_type ?? ""),
|
|
778
|
+
cwd: typeof event.cwd === "string" ? event.cwd : undefined,
|
|
779
|
+
output_hash: output ? sha256_text(output) : undefined,
|
|
780
|
+
status: typeof event.status === "string" ? event.status : "stopped",
|
|
781
|
+
error: typeof event.error === "string" ? event.error : undefined,
|
|
782
|
+
stopped_at: nowIso(),
|
|
783
|
+
};
|
|
784
|
+
writeRunlogRecord(ctx, record);
|
|
785
|
+
return statusDecision(ctx, "hook_record_subagent_stop", {
|
|
786
|
+
actions: [{ trusted_runlog_record_written: false, audit_only_runlog: toPosix(relative(ctx.changeRoot, runlogPath(ctx.changeRoot))) }],
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
export function hookInitReasons(repoRoot) {
|
|
791
|
+
const hookPath = join(repoRoot, ".codex", "hooks.json");
|
|
792
|
+
if (!existsSync(hookPath))
|
|
793
|
+
return [];
|
|
794
|
+
if (managedHooksManifestPresent(repoRoot))
|
|
795
|
+
return [];
|
|
796
|
+
return [reason("hook_manifest_unmanaged", ".codex/hooks.json exists but is not the SuperSpec-managed v2 hook manifest; strict profile must downgrade")];
|
|
797
|
+
}
|