agentxchain 0.8.8 → 2.1.1

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.
Files changed (74) hide show
  1. package/README.md +126 -142
  2. package/bin/agentxchain.js +186 -5
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +14 -6
  13. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  14. package/scripts/publish-from-tag.sh +88 -0
  15. package/scripts/release-postflight.sh +231 -0
  16. package/scripts/release-preflight.sh +167 -0
  17. package/src/commands/accept-turn.js +160 -0
  18. package/src/commands/approve-completion.js +80 -0
  19. package/src/commands/approve-transition.js +85 -0
  20. package/src/commands/dashboard.js +70 -0
  21. package/src/commands/init.js +516 -0
  22. package/src/commands/migrate.js +348 -0
  23. package/src/commands/multi.js +549 -0
  24. package/src/commands/plugin.js +157 -0
  25. package/src/commands/reject-turn.js +204 -0
  26. package/src/commands/resume.js +389 -0
  27. package/src/commands/status.js +196 -3
  28. package/src/commands/step.js +947 -0
  29. package/src/commands/template-list.js +33 -0
  30. package/src/commands/template-set.js +279 -0
  31. package/src/commands/validate.js +20 -11
  32. package/src/commands/verify.js +71 -0
  33. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  34. package/src/lib/adapters/local-cli-adapter.js +337 -0
  35. package/src/lib/adapters/manual-adapter.js +169 -0
  36. package/src/lib/blocked-state.js +94 -0
  37. package/src/lib/config.js +97 -1
  38. package/src/lib/context-compressor.js +121 -0
  39. package/src/lib/context-section-parser.js +220 -0
  40. package/src/lib/coordinator-acceptance.js +428 -0
  41. package/src/lib/coordinator-config.js +461 -0
  42. package/src/lib/coordinator-dispatch.js +276 -0
  43. package/src/lib/coordinator-gates.js +487 -0
  44. package/src/lib/coordinator-hooks.js +239 -0
  45. package/src/lib/coordinator-recovery.js +523 -0
  46. package/src/lib/coordinator-state.js +365 -0
  47. package/src/lib/cross-repo-context.js +247 -0
  48. package/src/lib/dashboard/bridge-server.js +284 -0
  49. package/src/lib/dashboard/file-watcher.js +93 -0
  50. package/src/lib/dashboard/state-reader.js +96 -0
  51. package/src/lib/dispatch-bundle.js +568 -0
  52. package/src/lib/dispatch-manifest.js +252 -0
  53. package/src/lib/gate-evaluator.js +285 -0
  54. package/src/lib/governed-state.js +2139 -0
  55. package/src/lib/governed-templates.js +145 -0
  56. package/src/lib/hook-runner.js +788 -0
  57. package/src/lib/normalized-config.js +539 -0
  58. package/src/lib/plugin-config-schema.js +192 -0
  59. package/src/lib/plugins.js +692 -0
  60. package/src/lib/protocol-conformance.js +291 -0
  61. package/src/lib/reference-conformance-adapter.js +717 -0
  62. package/src/lib/repo-observer.js +597 -0
  63. package/src/lib/repo.js +0 -31
  64. package/src/lib/schema.js +121 -0
  65. package/src/lib/schemas/turn-result.schema.json +205 -0
  66. package/src/lib/token-budget.js +206 -0
  67. package/src/lib/token-counter.js +27 -0
  68. package/src/lib/turn-paths.js +67 -0
  69. package/src/lib/turn-result-validator.js +496 -0
  70. package/src/lib/validation.js +137 -0
  71. package/src/templates/governed/api-service.json +31 -0
  72. package/src/templates/governed/cli-tool.json +30 -0
  73. package/src/templates/governed/generic.json +10 -0
  74. package/src/templates/governed/web-app.json +30 -0
@@ -0,0 +1,788 @@
1
+ /**
2
+ * Hook execution engine for AgentXchain governed orchestration.
3
+ *
4
+ * Implements the repo-local hook lifecycle per PLUGIN_HOOK_SYSTEM_SPEC.md:
5
+ * - Spawns hook processes with JSON stdin / JSON stdout contract
6
+ * - Enforces SHA-256 tamper detection on protected files
7
+ * - Records all invocations in hook-audit.jsonl
8
+ * - Records after_acceptance annotations in hook-annotations.jsonl
9
+ * - Enforces time-bounded execution with subprocess timeout
10
+ * - Advisory hooks cannot block; blocking verdict is downgraded to warn
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, rmSync } from 'fs';
14
+ import { join, isAbsolute, dirname } from 'path';
15
+ import { createHash } from 'crypto';
16
+ import { spawnSync, execFileSync } from 'child_process';
17
+
18
+ // ── Constants ────────────────────────────────────────────────────────────────
19
+
20
+ const HOOK_AUDIT_PATH = '.agentxchain/hook-audit.jsonl';
21
+ const HOOK_ANNOTATIONS_PATH = '.agentxchain/hook-annotations.jsonl';
22
+ const SIGKILL_GRACE_MS = 2000;
23
+
24
+ const VALID_HOOK_PHASES = [
25
+ 'before_assignment',
26
+ 'after_dispatch',
27
+ 'before_validation',
28
+ 'after_validation',
29
+ 'before_acceptance',
30
+ 'after_acceptance',
31
+ 'before_gate',
32
+ 'on_escalation',
33
+ ];
34
+
35
+ const NON_BLOCKING_PHASES = new Set(['after_acceptance', 'on_escalation']);
36
+ const MAX_HOOKS_PER_PHASE = 8;
37
+ const VALID_VERDICTS = new Set(['allow', 'warn', 'block']);
38
+ const ANNOTATION_KEY_RE = /^[a-z0-9_-]+$/;
39
+ const MAX_ANNOTATIONS = 16;
40
+ const MAX_ANNOTATION_VALUE_LENGTH = 1000;
41
+ const MAX_STDERR_CAPTURE = 4096;
42
+ const HEADER_VAR_RE = /\$\{([^}]+)\}/g;
43
+
44
+ // ── Protected Files ──────────────────────────────────────────────────────────
45
+
46
+ const PROTECTED_FILES = [
47
+ '.agentxchain/state.json',
48
+ '.agentxchain/history.jsonl',
49
+ '.agentxchain/decision-ledger.jsonl',
50
+ ];
51
+
52
+ // ── Executable Resolution ────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Resolve a hook command[0] to verify it is an executable.
56
+ * Resolution order:
57
+ * 1. If absolute path → check existsSync
58
+ * 2. If relative path (contains '/') → resolve against projectRoot, check existsSync
59
+ * 3. Otherwise → look up via `which` (PATH resolution)
60
+ *
61
+ * @param {string} executable - the command[0] value
62
+ * @param {string|null} projectRoot - project root for relative resolution
63
+ * @returns {{ resolved: boolean, path?: string, error?: string }}
64
+ */
65
+ export function resolveExecutable(executable, projectRoot) {
66
+ if (!executable || typeof executable !== 'string') {
67
+ return { resolved: false, error: 'executable must be a non-empty string' };
68
+ }
69
+
70
+ // Absolute path
71
+ if (isAbsolute(executable)) {
72
+ if (existsSync(executable)) {
73
+ return { resolved: true, path: executable };
74
+ }
75
+ return { resolved: false, error: `absolute path does not exist: ${executable}` };
76
+ }
77
+
78
+ // Relative path (contains a slash) — resolve against project root
79
+ if (executable.includes('/') && projectRoot) {
80
+ const resolved = join(projectRoot, executable);
81
+ if (existsSync(resolved)) {
82
+ return { resolved: true, path: resolved };
83
+ }
84
+ return { resolved: false, error: `relative path does not exist: ${executable} (resolved to ${resolved})` };
85
+ }
86
+
87
+ // Bare command name — look up via PATH using `which`
88
+ try {
89
+ const result = spawnSync('which', [executable], {
90
+ encoding: 'utf8',
91
+ timeout: 5000,
92
+ stdio: ['ignore', 'pipe', 'ignore'],
93
+ });
94
+ if (result.status === 0 && result.stdout.trim()) {
95
+ return { resolved: true, path: result.stdout.trim() };
96
+ }
97
+ } catch {
98
+ // which not available or failed — fall through
99
+ }
100
+
101
+ return { resolved: false, error: `command not found in PATH: ${executable}` };
102
+ }
103
+
104
+ // ── Config Validation ────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Validate the hooks section of a governed config.
108
+ * When projectRoot is provided, also resolves command[0] to verify executables exist.
109
+ * Returns { ok, errors }.
110
+ */
111
+ export function validateHooksConfig(hooks, projectRoot) {
112
+ const errors = [];
113
+
114
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) {
115
+ errors.push('hooks must be an object');
116
+ return { ok: false, errors };
117
+ }
118
+
119
+ for (const [phase, hookList] of Object.entries(hooks)) {
120
+ if (!VALID_HOOK_PHASES.includes(phase)) {
121
+ errors.push(`hooks: unknown phase "${phase}". Valid phases: ${VALID_HOOK_PHASES.join(', ')}`);
122
+ continue;
123
+ }
124
+
125
+ if (!Array.isArray(hookList)) {
126
+ errors.push(`hooks.${phase} must be an array`);
127
+ continue;
128
+ }
129
+
130
+ if (hookList.length > MAX_HOOKS_PER_PHASE) {
131
+ errors.push(`hooks.${phase}: maximum ${MAX_HOOKS_PER_PHASE} hooks per phase`);
132
+ }
133
+
134
+ const names = new Set();
135
+ for (let i = 0; i < hookList.length; i++) {
136
+ const hook = hookList[i];
137
+ const label = `hooks.${phase}[${i}]`;
138
+
139
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
140
+ errors.push(`${label} must be an object`);
141
+ continue;
142
+ }
143
+
144
+ // name
145
+ if (typeof hook.name !== 'string' || !hook.name.trim()) {
146
+ errors.push(`${label}: name must be a non-empty string`);
147
+ } else if (!/^[a-z0-9_-]+$/.test(hook.name)) {
148
+ errors.push(`${label}: name must match ^[a-z0-9_-]+$`);
149
+ } else if (names.has(hook.name)) {
150
+ errors.push(`${label}: duplicate hook name "${hook.name}" in phase ${phase}`);
151
+ } else {
152
+ names.add(hook.name);
153
+ }
154
+
155
+ // type
156
+ if (hook.type !== 'process' && hook.type !== 'http') {
157
+ errors.push(`${label}: type must be "process" or "http"`);
158
+ }
159
+
160
+ // type-specific validation
161
+ if (hook.type === 'process') {
162
+ // command (argv array)
163
+ if (!Array.isArray(hook.command) || hook.command.length === 0) {
164
+ errors.push(`${label}: command must be a non-empty array of strings`);
165
+ } else {
166
+ let commandValid = true;
167
+ for (let j = 0; j < hook.command.length; j++) {
168
+ if (typeof hook.command[j] !== 'string') {
169
+ errors.push(`${label}: command[${j}] must be a string`);
170
+ commandValid = false;
171
+ }
172
+ }
173
+ // Resolve command[0] as executable when projectRoot is available
174
+ if (commandValid && projectRoot) {
175
+ const resolution = resolveExecutable(hook.command[0], projectRoot);
176
+ if (!resolution.resolved) {
177
+ errors.push(`${label}: ${resolution.error}`);
178
+ }
179
+ }
180
+ }
181
+ } else if (hook.type === 'http') {
182
+ // url
183
+ if (typeof hook.url !== 'string' || !hook.url.trim()) {
184
+ errors.push(`${label}: url must be a non-empty string`);
185
+ } else if (!/^https?:\/\/.+/.test(hook.url)) {
186
+ errors.push(`${label}: url must be a valid HTTP or HTTPS URL`);
187
+ }
188
+ // method
189
+ if (hook.method !== 'POST') {
190
+ errors.push(`${label}: method must be "POST" (required; only POST is supported)`);
191
+ }
192
+ // headers (optional)
193
+ if ('headers' in hook && hook.headers !== undefined) {
194
+ if (!hook.headers || typeof hook.headers !== 'object' || Array.isArray(hook.headers)) {
195
+ errors.push(`${label}: headers must be an object`);
196
+ } else {
197
+ for (const [hk, hv] of Object.entries(hook.headers)) {
198
+ if (typeof hv !== 'string') {
199
+ errors.push(`${label}: headers.${hk} must be a string`);
200
+ }
201
+ }
202
+ const missingHeaderVars = collectMissingHeaderVars(hook.headers, hook.env);
203
+ if (missingHeaderVars.length > 0) {
204
+ errors.push(
205
+ `${label}: unresolved header env vars ${missingHeaderVars.map(({ header, varName }) => `${header}:${varName}`).join(', ')}`,
206
+ );
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // timeout_ms
213
+ if (!Number.isInteger(hook.timeout_ms) || hook.timeout_ms < 100 || hook.timeout_ms > 30000) {
214
+ errors.push(`${label}: timeout_ms must be an integer between 100 and 30000`);
215
+ }
216
+
217
+ // mode
218
+ if (hook.mode !== 'blocking' && hook.mode !== 'advisory') {
219
+ errors.push(`${label}: mode must be "blocking" or "advisory"`);
220
+ } else if (hook.mode === 'blocking' && NON_BLOCKING_PHASES.has(phase)) {
221
+ errors.push(`${label}: phase "${phase}" does not support blocking hooks`);
222
+ }
223
+
224
+ // env (optional)
225
+ if ('env' in hook && hook.env !== undefined) {
226
+ if (!hook.env || typeof hook.env !== 'object' || Array.isArray(hook.env)) {
227
+ errors.push(`${label}: env must be an object`);
228
+ } else {
229
+ for (const [k, v] of Object.entries(hook.env)) {
230
+ if (typeof v !== 'string') {
231
+ errors.push(`${label}: env.${k} must be a string`);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ return { ok: errors.length === 0, errors };
240
+ }
241
+
242
+ // ── SHA-256 Digest Helpers ───────────────────────────────────────────────────
243
+
244
+ function computeFileDigest(filePath) {
245
+ if (!existsSync(filePath)) return null;
246
+ const content = readFileSync(filePath);
247
+ return createHash('sha256').update(content).digest('hex');
248
+ }
249
+
250
+ function captureProtectedSnapshots(root, extraProtectedPaths = []) {
251
+ const snapshots = {};
252
+ const protectedPaths = [...new Set([...PROTECTED_FILES, ...extraProtectedPaths])];
253
+ for (const relPath of protectedPaths) {
254
+ const absPath = join(root, relPath);
255
+ snapshots[relPath] = existsSync(absPath) ? readFileSync(absPath) : null;
256
+ }
257
+ return snapshots;
258
+ }
259
+
260
+ function captureProtectedDigests(root, extraProtectedPaths = []) {
261
+ const digests = {};
262
+ const protectedPaths = [...new Set([...PROTECTED_FILES, ...extraProtectedPaths])];
263
+ for (const relPath of protectedPaths) {
264
+ digests[relPath] = computeFileDigest(join(root, relPath));
265
+ }
266
+ return digests;
267
+ }
268
+
269
+ function verifyProtectedDigests(root, preDigests, extraProtectedPaths = []) {
270
+ const protectedPaths = [...new Set([...PROTECTED_FILES, ...extraProtectedPaths])];
271
+ for (const relPath of protectedPaths) {
272
+ const postDigest = computeFileDigest(join(root, relPath));
273
+ if (preDigests[relPath] !== postDigest) {
274
+ const errorCode = relPath.endsWith('state.json')
275
+ ? 'hook_state_tamper'
276
+ : relPath.endsWith('history.jsonl')
277
+ ? 'hook_history_tamper'
278
+ : relPath.endsWith('decision-ledger.jsonl')
279
+ ? 'hook_ledger_tamper'
280
+ : 'hook_bundle_tamper';
281
+ return {
282
+ tampered: true,
283
+ file: relPath,
284
+ error_code: errorCode,
285
+ message: `Hook tampered with protected file ${relPath} (SHA-256 digest mismatch)`,
286
+ };
287
+ }
288
+ }
289
+ return { tampered: false };
290
+ }
291
+
292
+ function restoreProtectedSnapshots(root, snapshots) {
293
+ for (const [relPath, content] of Object.entries(snapshots || {})) {
294
+ const absPath = join(root, relPath);
295
+ if (content === null) {
296
+ if (existsSync(absPath)) {
297
+ rmSync(absPath, { force: true });
298
+ }
299
+ continue;
300
+ }
301
+
302
+ mkdirSync(dirname(absPath), { recursive: true });
303
+ writeFileSync(absPath, content);
304
+ }
305
+ }
306
+
307
+ // ── Audit Trail ──────────────────────────────────────────────────────────────
308
+
309
+ function appendAuditEntry(root, entry, auditDir) {
310
+ const dir = auditDir || join(root, '.agentxchain');
311
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
312
+ const filePath = auditDir
313
+ ? join(auditDir, 'hook-audit.jsonl')
314
+ : join(root, HOOK_AUDIT_PATH);
315
+ appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf8');
316
+ }
317
+
318
+ function appendAnnotationEntry(root, entry, auditDir) {
319
+ const dir = auditDir || join(root, '.agentxchain');
320
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
321
+ const filePath = auditDir
322
+ ? join(auditDir, 'hook-annotations.jsonl')
323
+ : join(root, HOOK_ANNOTATIONS_PATH);
324
+ appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf8');
325
+ }
326
+
327
+ // ── Verdict Parsing ──────────────────────────────────────────────────────────
328
+
329
+ function parseVerdict(stdout) {
330
+ if (!stdout || !stdout.trim()) return null;
331
+ try {
332
+ const parsed = JSON.parse(stdout.trim());
333
+ if (!parsed || typeof parsed !== 'object') return null;
334
+ if (!VALID_VERDICTS.has(parsed.verdict)) return null;
335
+ return parsed;
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ function validateAnnotations(annotations) {
342
+ if (!Array.isArray(annotations)) return [];
343
+ const valid = [];
344
+ for (let i = 0; i < Math.min(annotations.length, MAX_ANNOTATIONS); i++) {
345
+ const ann = annotations[i];
346
+ if (!ann || typeof ann !== 'object') continue;
347
+ if (typeof ann.key !== 'string' || !ANNOTATION_KEY_RE.test(ann.key)) continue;
348
+ if (typeof ann.value !== 'string') continue;
349
+ valid.push({
350
+ key: ann.key,
351
+ value: ann.value.slice(0, MAX_ANNOTATION_VALUE_LENGTH),
352
+ });
353
+ }
354
+ return valid;
355
+ }
356
+
357
+ /**
358
+ * Normalize spawnSync errors for hook processes.
359
+ *
360
+ * Some platforms can report an `EPIPE` write error when the child exits
361
+ * successfully without consuming the JSON stdin envelope. A zero exit status
362
+ * still represents a successful hook execution in that case, so do not
363
+ * misclassify it as a hook failure.
364
+ *
365
+ * @param {import('child_process').SpawnSyncReturns<Buffer|string>} result
366
+ * @returns {string|null}
367
+ */
368
+ export function normalizeHookProcessError(result) {
369
+ if (!result?.error) return null;
370
+
371
+ const errorCode = result.error.code || null;
372
+ const errorMessage = result.error.message || String(result.error);
373
+
374
+ if (result.status === 0 && errorCode === 'EPIPE') {
375
+ return null;
376
+ }
377
+
378
+ return errorMessage;
379
+ }
380
+
381
+ // ── Header Interpolation ────────────────────────────────────────────────────
382
+
383
+ /**
384
+ * Resolve `${VAR_NAME}` placeholders in header values from hook env + process.env.
385
+ */
386
+ function collectMissingHeaderVars(headers, hookEnv) {
387
+ if (!headers) return [];
388
+ const missing = [];
389
+ const mergedEnv = { ...process.env };
390
+
391
+ for (const [key, value] of Object.entries(hookEnv || {})) {
392
+ if (typeof value === 'string') {
393
+ mergedEnv[key] = value;
394
+ }
395
+ }
396
+
397
+ for (const [headerName, value] of Object.entries(headers)) {
398
+ let match;
399
+ while ((match = HEADER_VAR_RE.exec(value)) !== null) {
400
+ if (mergedEnv[match[1]] === undefined) {
401
+ missing.push({ header: headerName, varName: match[1] });
402
+ }
403
+ }
404
+ HEADER_VAR_RE.lastIndex = 0;
405
+ }
406
+
407
+ return missing;
408
+ }
409
+
410
+ function interpolateHeaders(headers, hookEnv, options = {}) {
411
+ if (!headers) return {};
412
+ const missing = collectMissingHeaderVars(headers, hookEnv);
413
+ if (!options.allowUnresolved && missing.length > 0) {
414
+ throw new Error(
415
+ `Unresolved HTTP hook header variables: ${missing.map(({ header, varName }) => `${header}:${varName}`).join(', ')}`,
416
+ );
417
+ }
418
+
419
+ const resolved = {};
420
+ const mergedEnv = { ...process.env, ...(hookEnv || {}) };
421
+ for (const [key, value] of Object.entries(headers)) {
422
+ resolved[key] = value.replace(HEADER_VAR_RE, (_match, varName) => {
423
+ const envVal = mergedEnv[varName];
424
+ if (envVal === undefined) return '';
425
+ return envVal;
426
+ });
427
+ HEADER_VAR_RE.lastIndex = 0;
428
+ }
429
+ return resolved;
430
+ }
431
+
432
+ // ── HTTP Hook Execution ─────────────────────────────────────────────────────
433
+
434
+ /**
435
+ * Execute a single HTTP hook via a synchronous child process bridge.
436
+ *
437
+ * Uses `node -e` to perform the HTTP fetch synchronously without making the
438
+ * hook runner async. The child process writes the response JSON to stdout.
439
+ *
440
+ * @param {object} hookDef - hook config definition
441
+ * @param {object} payload - JSON envelope payload
442
+ * @returns {object} execution result (same shape as executeHookProcess)
443
+ */
444
+ function executeHttpHook(hookDef, payload) {
445
+ const startTime = Date.now();
446
+ let resolvedHeaders;
447
+ try {
448
+ resolvedHeaders = interpolateHeaders(hookDef.headers, hookDef.env);
449
+ } catch (error) {
450
+ return {
451
+ timedOut: false,
452
+ stdout: '',
453
+ stderr: String(error?.message || error).slice(0, MAX_STDERR_CAPTURE),
454
+ exitCode: 1,
455
+ durationMs: Date.now() - startTime,
456
+ processError: String(error?.message || error),
457
+ };
458
+ }
459
+ resolvedHeaders['Content-Type'] = 'application/json';
460
+
461
+ const fetchScript = `
462
+ const http = require('http');
463
+ const https = require('https');
464
+ const url = new URL(${JSON.stringify(hookDef.url)});
465
+ const headers = ${JSON.stringify(resolvedHeaders)};
466
+ const body = process.argv[1];
467
+ const mod = url.protocol === 'https:' ? https : http;
468
+ const req = mod.request(url, { method: 'POST', headers, timeout: ${hookDef.timeout_ms} }, (res) => {
469
+ let data = '';
470
+ res.on('data', (chunk) => { data += chunk; });
471
+ res.on('end', () => {
472
+ process.stdout.write(JSON.stringify({ status: res.statusCode, body: data }));
473
+ });
474
+ });
475
+ req.on('timeout', () => { req.destroy(); process.stderr.write('timeout'); process.exit(2); });
476
+ req.on('error', (e) => { process.stderr.write(e.message); process.exit(1); });
477
+ req.write(body);
478
+ req.end();
479
+ `;
480
+
481
+ const result = spawnSync(process.execPath, ['-e', fetchScript, JSON.stringify(payload)], {
482
+ timeout: hookDef.timeout_ms + SIGKILL_GRACE_MS,
483
+ maxBuffer: 1024 * 1024,
484
+ stdio: ['ignore', 'pipe', 'pipe'],
485
+ env: { ...process.env, ...(hookDef.env || {}) },
486
+ });
487
+ const durationMs = Date.now() - startTime;
488
+
489
+ const timedOut = result.error?.code === 'ETIMEDOUT' || durationMs > hookDef.timeout_ms;
490
+ const rawStdout = result.stdout ? result.stdout.toString('utf8') : '';
491
+ const stderr = result.stderr ? result.stderr.toString('utf8').slice(0, MAX_STDERR_CAPTURE) : '';
492
+ const exitCode = result.status;
493
+
494
+ // Parse the bridge response to extract the HTTP response body as the "stdout" for verdict parsing
495
+ let stdout = '';
496
+ if (!timedOut && exitCode === 0 && rawStdout) {
497
+ try {
498
+ const bridgeResponse = JSON.parse(rawStdout);
499
+ if (bridgeResponse.status >= 200 && bridgeResponse.status < 300) {
500
+ stdout = bridgeResponse.body || '';
501
+ } else {
502
+ // Non-2xx → treat as failure
503
+ return {
504
+ timedOut: false,
505
+ stdout: '',
506
+ stderr: `HTTP ${bridgeResponse.status}: ${bridgeResponse.body?.slice(0, 200) || ''}`,
507
+ exitCode: 1,
508
+ durationMs,
509
+ processError: `HTTP hook returned status ${bridgeResponse.status}`,
510
+ };
511
+ }
512
+ } catch {
513
+ // Could not parse bridge output
514
+ return {
515
+ timedOut: false,
516
+ stdout: '',
517
+ stderr: `Failed to parse HTTP bridge response: ${rawStdout.slice(0, 200)}`,
518
+ exitCode: 1,
519
+ durationMs,
520
+ processError: 'HTTP bridge response parse error',
521
+ };
522
+ }
523
+ }
524
+
525
+ return {
526
+ timedOut,
527
+ stdout,
528
+ stderr,
529
+ exitCode,
530
+ durationMs,
531
+ processError: normalizeHookProcessError(result),
532
+ };
533
+ }
534
+
535
+ // ── Hook Execution ───────────────────────────────────────────────────────────
536
+
537
+ /**
538
+ * Execute a single hook process.
539
+ *
540
+ * @param {string} root - project root
541
+ * @param {object} hookDef - hook config definition
542
+ * @param {object} payload - JSON payload for stdin
543
+ * @returns {object} execution result
544
+ */
545
+ function executeHookProcess(root, hookDef, payload) {
546
+ const stdinData = JSON.stringify(payload);
547
+ const env = {
548
+ ...process.env,
549
+ AGENTXCHAIN_HOOK_PHASE: payload.hook_phase,
550
+ AGENTXCHAIN_HOOK_NAME: hookDef.name,
551
+ AGENTXCHAIN_RUN_ID: payload.run_id || '',
552
+ AGENTXCHAIN_PROJECT_ROOT: root,
553
+ ...(hookDef.env || {}),
554
+ };
555
+
556
+ const startTime = Date.now();
557
+ const result = spawnSync(hookDef.command[0], hookDef.command.slice(1), {
558
+ cwd: root,
559
+ env,
560
+ input: stdinData,
561
+ timeout: hookDef.timeout_ms + SIGKILL_GRACE_MS,
562
+ maxBuffer: 1024 * 1024,
563
+ stdio: ['pipe', 'pipe', 'pipe'],
564
+ killSignal: 'SIGTERM',
565
+ });
566
+ const durationMs = Date.now() - startTime;
567
+
568
+ const timedOut = result.error?.code === 'ETIMEDOUT' || durationMs > hookDef.timeout_ms;
569
+ const stdout = result.stdout ? result.stdout.toString('utf8') : '';
570
+ const stderr = result.stderr ? result.stderr.toString('utf8').slice(0, MAX_STDERR_CAPTURE) : '';
571
+ const exitCode = result.status;
572
+ const processError = normalizeHookProcessError(result);
573
+
574
+ return {
575
+ timedOut,
576
+ stdout,
577
+ stderr,
578
+ exitCode,
579
+ durationMs,
580
+ processError,
581
+ };
582
+ }
583
+
584
+ // ── Main Hook Runner ─────────────────────────────────────────────────────────
585
+
586
+ /**
587
+ * Run all hooks for a given phase.
588
+ *
589
+ * @param {string} root - project root
590
+ * @param {object} config - normalized config (must have raw hooks in rawConfig or config.hooks)
591
+ * @param {string} phase - hook phase name
592
+ * @param {object} payload - phase-specific payload (without envelope fields)
593
+ * @param {object} [options] - additional options
594
+ * @param {string} [options.run_id] - run ID
595
+ * @param {string} [options.turn_id] - turn ID (for audit)
596
+ * @param {string} [options.auditDir] - custom directory for audit/annotation files (default: <root>/.agentxchain)
597
+ * @returns {{ ok: boolean, blocked?: boolean, blocker?: object, tamper?: object, results: object[] }}
598
+ */
599
+ export function runHooks(root, hooksConfig, phase, payload, options = {}) {
600
+ const hookList = hooksConfig?.[phase];
601
+ if (!hookList || !Array.isArray(hookList) || hookList.length === 0) {
602
+ return { ok: true, blocked: false, results: [] };
603
+ }
604
+
605
+ const results = [];
606
+ const now = () => new Date().toISOString();
607
+ const _auditDir = options.auditDir || null;
608
+ const protectedPaths = Array.isArray(options.protectedPaths)
609
+ ? options.protectedPaths.filter((relPath) => typeof relPath === 'string' && relPath.trim())
610
+ : [];
611
+
612
+ for (const hookDef of hookList) {
613
+ // Capture protected file digests before hook execution
614
+ const preSnapshots = captureProtectedSnapshots(root, protectedPaths);
615
+ const preDigests = captureProtectedDigests(root, protectedPaths);
616
+
617
+ // Build envelope payload
618
+ const envelope = {
619
+ hook_phase: phase,
620
+ hook_name: hookDef.name,
621
+ run_id: options.run_id || '',
622
+ project_root: root,
623
+ timestamp: now(),
624
+ payload,
625
+ };
626
+
627
+ // Execute hook (process or HTTP transport)
628
+ const exec = hookDef.type === 'http'
629
+ ? executeHttpHook(hookDef, envelope)
630
+ : executeHookProcess(root, hookDef, envelope);
631
+
632
+ // Verify tamper detection
633
+ const tamperCheck = verifyProtectedDigests(root, preDigests, protectedPaths);
634
+ if (tamperCheck.tampered) {
635
+ restoreProtectedSnapshots(root, preSnapshots);
636
+ const auditEntry = {
637
+ timestamp: now(),
638
+ hook_phase: phase,
639
+ hook_name: hookDef.name,
640
+ transport: hookDef.type || 'process',
641
+ run_id: options.run_id || '',
642
+ turn_id: options.turn_id || null,
643
+ duration_ms: exec.durationMs,
644
+ verdict: null,
645
+ message: `${tamperCheck.message}. Protected content restored.`,
646
+ annotations: [],
647
+ exit_code: exec.exitCode,
648
+ timed_out: exec.timedOut,
649
+ stderr_excerpt: exec.stderr,
650
+ orchestrator_action: 'aborted_tamper',
651
+ };
652
+ appendAuditEntry(root, auditEntry, _auditDir);
653
+ results.push(auditEntry);
654
+
655
+ return {
656
+ ok: false,
657
+ blocked: false,
658
+ tamper: tamperCheck,
659
+ results,
660
+ };
661
+ }
662
+
663
+ // Parse verdict
664
+ let verdict;
665
+ let message = null;
666
+ let annotations = [];
667
+ let orchestratorAction;
668
+
669
+ if (exec.timedOut) {
670
+ // Timeout: fail-closed for blocking, warn for advisory
671
+ verdict = hookDef.mode === 'blocking' ? 'block' : 'warn';
672
+ message = `Hook "${hookDef.name}" timed out after ${hookDef.timeout_ms}ms`;
673
+ orchestratorAction = hookDef.mode === 'blocking' ? 'blocked_timeout' : 'warned_timeout';
674
+ } else if (exec.exitCode !== 0 || exec.processError) {
675
+ // Process failure: same treatment as timeout
676
+ verdict = hookDef.mode === 'blocking' ? 'block' : 'warn';
677
+ message = `Hook "${hookDef.name}" failed (exit code ${exec.exitCode})`;
678
+ orchestratorAction = hookDef.mode === 'blocking' ? 'blocked_failure' : 'warned_failure';
679
+ } else {
680
+ const parsed = parseVerdict(exec.stdout);
681
+ if (!parsed) {
682
+ // Invalid output: treat as process failure
683
+ verdict = hookDef.mode === 'blocking' ? 'block' : 'warn';
684
+ message = `Hook "${hookDef.name}" produced invalid JSON output`;
685
+ orchestratorAction = hookDef.mode === 'blocking' ? 'blocked_invalid_output' : 'warned_invalid_output';
686
+ } else {
687
+ verdict = parsed.verdict;
688
+ message = parsed.message || null;
689
+ annotations = validateAnnotations(parsed.annotations);
690
+
691
+ // Advisory hooks cannot block — downgrade to warn
692
+ if (hookDef.mode === 'advisory' && verdict === 'block') {
693
+ verdict = 'warn';
694
+ orchestratorAction = 'downgraded_block_to_warn';
695
+ } else if (verdict === 'block') {
696
+ orchestratorAction = 'blocked';
697
+ } else if (verdict === 'warn') {
698
+ orchestratorAction = 'warned';
699
+ } else {
700
+ orchestratorAction = 'continued';
701
+ }
702
+ }
703
+ }
704
+
705
+ // Build audit entry
706
+ const auditEntry = {
707
+ timestamp: now(),
708
+ hook_phase: phase,
709
+ hook_name: hookDef.name,
710
+ transport: hookDef.type || 'process',
711
+ run_id: options.run_id || '',
712
+ turn_id: options.turn_id || null,
713
+ duration_ms: exec.durationMs,
714
+ verdict,
715
+ message,
716
+ annotations,
717
+ exit_code: exec.exitCode,
718
+ timed_out: exec.timedOut,
719
+ stderr_excerpt: exec.stderr,
720
+ orchestrator_action: orchestratorAction,
721
+ };
722
+ appendAuditEntry(root, auditEntry, _auditDir);
723
+ results.push(auditEntry);
724
+
725
+ // Record annotations in hook-annotations.jsonl for after_acceptance phase
726
+ if (phase === 'after_acceptance' && annotations.length > 0) {
727
+ appendAnnotationEntry(root, {
728
+ timestamp: now(),
729
+ turn_id: options.turn_id || null,
730
+ hook_name: hookDef.name,
731
+ annotations,
732
+ }, _auditDir);
733
+ }
734
+
735
+ // Blocking hook returns block → short-circuit remaining hooks
736
+ if (verdict === 'block' && hookDef.mode === 'blocking') {
737
+ // Record skipped hooks in audit
738
+ const hookIndex = hookList.indexOf(hookDef);
739
+ for (let i = hookIndex + 1; i < hookList.length; i++) {
740
+ const skipped = {
741
+ timestamp: now(),
742
+ hook_phase: phase,
743
+ hook_name: hookList[i].name,
744
+ run_id: options.run_id || '',
745
+ turn_id: options.turn_id || null,
746
+ duration_ms: 0,
747
+ verdict: null,
748
+ message: `Skipped: prior hook "${hookDef.name}" blocked`,
749
+ annotations: [],
750
+ exit_code: null,
751
+ timed_out: false,
752
+ stderr_excerpt: '',
753
+ orchestrator_action: 'skipped',
754
+ };
755
+ appendAuditEntry(root, skipped, _auditDir);
756
+ results.push(skipped);
757
+ }
758
+
759
+ return {
760
+ ok: false,
761
+ blocked: true,
762
+ blocker: {
763
+ hook_name: hookDef.name,
764
+ verdict,
765
+ message,
766
+ },
767
+ results,
768
+ };
769
+ }
770
+ }
771
+
772
+ return { ok: true, blocked: false, results };
773
+ }
774
+
775
+ // ── Exports for testing ──────────────────────────────────────────────────────
776
+
777
+ export {
778
+ HOOK_AUDIT_PATH,
779
+ HOOK_ANNOTATIONS_PATH,
780
+ VALID_HOOK_PHASES,
781
+ NON_BLOCKING_PHASES,
782
+ PROTECTED_FILES,
783
+ captureProtectedDigests,
784
+ verifyProtectedDigests,
785
+ parseVerdict,
786
+ validateAnnotations,
787
+ interpolateHeaders,
788
+ };