@rulemetric/hooks 0.15.1 → 0.16.0
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/package.json +1 -1
- package/scripts/scoped-live.mjs +37 -12
package/package.json
CHANGED
package/scripts/scoped-live.mjs
CHANGED
|
@@ -2,11 +2,11 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { resolve, join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
|
|
8
8
|
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
-
export function readArtifact(root, path) {
|
|
9
|
+
export function readArtifact(root, path, maxBytes = 200_000) {
|
|
10
10
|
if (!path || path.startsWith('/') || path.includes('\\') || path.split('/').some(s => !s || s === '.' || s === '..')) throw new Error('Unsafe artifact path');
|
|
11
11
|
let current = resolve(root);
|
|
12
12
|
for (const segment of path.split('/')) {
|
|
@@ -15,10 +15,30 @@ export function readArtifact(root, path) {
|
|
|
15
15
|
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
16
16
|
}
|
|
17
17
|
const stat = lstatSync(current);
|
|
18
|
-
if (!stat.isFile() || stat.size >
|
|
18
|
+
if (!stat.isFile() || stat.size > maxBytes) throw new Error('Artifact is not a bounded regular file');
|
|
19
19
|
return readFileSync(current, 'utf8');
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Execute only the prospectively frozen check, never an actor's success claim. */
|
|
23
|
+
export function runRepositoryCheck(root, check) {
|
|
24
|
+
const definitionHash = hash(JSON.stringify({ name: check.name, path: check.path, kind: check.kind, expected: check.expected, primary: check.primary, command: check.command, timeoutMs: check.timeoutMs, fileHashes: Object.fromEntries(Object.entries(check.fileHashes).sort(([a], [b]) => a.localeCompare(b))) }));
|
|
25
|
+
const result = { name: check.name, definitionHash, exitCode: null, outputHash: null, error: null };
|
|
26
|
+
const unchanged = () => Object.entries(check.fileHashes).every(([path, expected]) => {
|
|
27
|
+
const content = readArtifact(root, path, 2_000_000);
|
|
28
|
+
return content !== null && hash(content) === expected;
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
if (!unchanged()) return { ...result, error: 'Verifier files changed' };
|
|
32
|
+
const child = spawnSync(check.command[0], check.command.slice(1), {
|
|
33
|
+
cwd: root, timeout: check.timeoutMs, maxBuffer: 200_000, encoding: 'utf8',
|
|
34
|
+
env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, CI: '1' },
|
|
35
|
+
});
|
|
36
|
+
if (!unchanged()) return { ...result, error: 'Verifier files changed' };
|
|
37
|
+
return { ...result, exitCode: child.status, outputHash: hash((child.stdout ?? '') + (child.stderr ?? '')),
|
|
38
|
+
error: child.error ? child.error.message : child.signal ? `Verifier terminated: ${child.signal}` : null };
|
|
39
|
+
} catch (error) { return { ...result, error: error.message }; }
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
export function observeEnvironment(root) {
|
|
23
43
|
const contextHashes = {};
|
|
24
44
|
const context = path => {
|
|
@@ -68,6 +88,17 @@ export function transcriptEvidence(path, sessionId) {
|
|
|
68
88
|
|
|
69
89
|
export function transcriptModels(path, sessionId) { return transcriptEvidence(path, sessionId).observedModels; }
|
|
70
90
|
|
|
91
|
+
export function collectScopedOutcome(protocol, taskId, sessionId, root, transcriptPath) {
|
|
92
|
+
const environment = observeEnvironment(root);
|
|
93
|
+
const task = protocol.verification.tasks.find(task => task.taskId === taskId);
|
|
94
|
+
if (!task) throw new Error('Unknown task');
|
|
95
|
+
const artifacts = [...new Set(task.checks.map(check => check.path))].map(path => {
|
|
96
|
+
try { return { path, content: readArtifact(root, path) }; }
|
|
97
|
+
catch (error) { return { path, content: null, error: error.message }; }
|
|
98
|
+
});
|
|
99
|
+
return { externalSessionId: sessionId, projectPath: root, environment, ...transcriptEvidence(transcriptPath, sessionId), artifacts, commands: task.checks.filter(check => check.kind === 'command_exit').map(check => runRepositoryCheck(root, check)) };
|
|
100
|
+
}
|
|
101
|
+
|
|
71
102
|
export async function runScopedHook(mode, sessionId, root, model, transcriptPath) {
|
|
72
103
|
const id = process.env.RULEMETRIC_SCOPED_CONFIRMATION_ID;
|
|
73
104
|
const taskId = process.env.RULEMETRIC_SCOPED_TASK_ID;
|
|
@@ -86,21 +117,15 @@ export async function runScopedHook(mode, sessionId, root, model, transcriptPath
|
|
|
86
117
|
const protocol = confirmation.protocol;
|
|
87
118
|
const environment = protocol.verification ? observeEnvironment(root) : undefined;
|
|
88
119
|
if (mode === 'start') {
|
|
89
|
-
const configurationHash = environment ? hash(JSON.stringify(environment)) : process.env.RULEMETRIC_SCOPED_CONFIGURATION_HASH;
|
|
90
|
-
const rendered = await request(`/scoped-live-confirmations/${id}/hook-render`, { taskId, externalSessionId: sessionId, model, projectPath: root, configurationHash, environment });
|
|
120
|
+
const configurationHash = environment ? hash(JSON.stringify(protocol.research?.enrollment === 'prospective-launches' ? { ...environment, repositoryRevision: protocol.verification.environment.repositoryRevision } : environment)) : process.env.RULEMETRIC_SCOPED_CONFIGURATION_HASH;
|
|
121
|
+
const rendered = await request(`/scoped-live-confirmations/${id}/hook-render`, { taskId, externalSessionId: sessionId, model, projectPath: root, configurationHash, environment, collectorVersion: 2 });
|
|
91
122
|
const content = Buffer.from(rendered.contentBase64, 'base64');
|
|
92
123
|
if (hash(content) !== rendered.deliveryHash) throw new Error('Scoped delivery hash mismatch');
|
|
93
124
|
// No unhashed headings or wrappers are added to the treatment.
|
|
94
125
|
await new Promise((resolve, reject) => process.stdout.write(content, error => error ? reject(error) : resolve()));
|
|
95
126
|
await request(`/scoped-live-confirmations/${id}/assignments/${rendered.assignment.id}/delivery`, { deliveryHash: rendered.deliveryHash });
|
|
96
127
|
} else if (mode === 'end' && protocol.verification) {
|
|
97
|
-
|
|
98
|
-
if (!task) throw new Error('Unknown task');
|
|
99
|
-
const artifacts = [...new Set(task.checks.map(check => check.path))].map(path => {
|
|
100
|
-
try { return { path, content: readArtifact(root, path) }; }
|
|
101
|
-
catch (error) { return { path, content: null, error: error.message }; }
|
|
102
|
-
});
|
|
103
|
-
await request(`/scoped-live-confirmations/${id}/verify`, { externalSessionId: sessionId, projectPath: root, environment, ...transcriptEvidence(transcriptPath, sessionId), artifacts });
|
|
128
|
+
await request(`/scoped-live-confirmations/${id}/verify`, collectScopedOutcome(protocol, taskId, sessionId, root, transcriptPath));
|
|
104
129
|
// A non-final task legitimately returns 409; its receipt is already durable.
|
|
105
130
|
await request(`/scoped-live-confirmations/${id}/decide`, {}).catch(() => {});
|
|
106
131
|
}
|