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.
- package/README.md +126 -142
- package/bin/agentxchain.js +186 -5
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +14 -6
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/init.js +516 -0
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +97 -1
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +717 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/schema.js +121 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +137 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- 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
|
+
};
|