@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/hooks",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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 > 200_000) throw new Error('Artifact is not a bounded regular file');
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
- const task = protocol.verification.tasks.find(task => task.taskId === taskId);
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
  }