@rulemetric/hooks 0.15.1 → 0.17.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/_log.sh +1 -1
- package/scripts/scoped-live.mjs +64 -15
package/package.json
CHANGED
package/scripts/_log.sh
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Appends JSON lines to $TMPDIR/rulemetric/hook.log
|
|
4
4
|
# Never fails — all writes guarded with || true
|
|
5
5
|
|
|
6
|
-
if ! command -v jq >/dev/null 2>&1; then
|
|
6
|
+
if ! command -v jq >/dev/null 2>&1 || [ ! -x "$(command -v jq)" ]; then
|
|
7
7
|
_rulemetric_log_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
8
|
# npm normalises tarball file modes to 0644, so the bundled shim arrives
|
|
9
9
|
# NON-EXECUTABLE from a published install — `command -v jq` then never finds
|
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 => {
|
|
@@ -32,8 +52,13 @@ export function observeEnvironment(root) {
|
|
|
32
52
|
for (const file of ['settings.json', 'CLAUDE.md']) context(join(homedir(), '.claude', file));
|
|
33
53
|
const userConfig = join(homedir(), '.claude.json');
|
|
34
54
|
context(userConfig);
|
|
35
|
-
|
|
55
|
+
const config = contextHashes[userConfig] ? JSON.parse(readFileSync(userConfig, 'utf8')) : null;
|
|
56
|
+
if (config) contextHashes[userConfig] = hash(JSON.stringify(stableClaudeConfig(config, resolve(root))));
|
|
57
|
+
const runtimeStateHash = config ? hash(JSON.stringify(Object.fromEntries(
|
|
58
|
+
['cachedDynamicConfigs', 'cachedGrowthBookFeatures', 'cachedStatsigGates', 'cachedExperimentFeatures', 'cachedExperimentData'].map(key => [key, config[key] ?? null]),
|
|
59
|
+
))) : null;
|
|
36
60
|
return {
|
|
61
|
+
runtimeStateHash,
|
|
37
62
|
harnessVersion: execFileSync('claude', ['--version'], { encoding: 'utf8', timeout: 5000 }).trim(),
|
|
38
63
|
repositoryRevision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 }).trim(),
|
|
39
64
|
contextHashes: Object.fromEntries(Object.entries(contextHashes).sort(([a], [b]) => a.localeCompare(b))),
|
|
@@ -43,7 +68,7 @@ export function observeEnvironment(root) {
|
|
|
43
68
|
// .claude.json mixes session counters/history with configuration. Retain only
|
|
44
69
|
// configuration here; settings files are separately hashed in full above.
|
|
45
70
|
export function stableClaudeConfig(config, root) {
|
|
46
|
-
const keys = ['mcpServers', 'mcpContextUris', 'allowedTools', 'enabledMcpjsonServers', 'disabledMcpjsonServers', 'disabledMcpServers', 'hasClaudeMdExternalIncludesApproved', 'claudeInChromeDefaultEnabled'
|
|
71
|
+
const keys = ['mcpServers', 'mcpContextUris', 'allowedTools', 'enabledMcpjsonServers', 'disabledMcpjsonServers', 'disabledMcpServers', 'hasClaudeMdExternalIncludesApproved', 'claudeInChromeDefaultEnabled'];
|
|
47
72
|
const pick = object => Object.fromEntries(keys.map(key => [key, object?.[key] ?? null]));
|
|
48
73
|
return { global: pick(config), project: pick(config.projects?.[root]) };
|
|
49
74
|
}
|
|
@@ -68,6 +93,17 @@ export function transcriptEvidence(path, sessionId) {
|
|
|
68
93
|
|
|
69
94
|
export function transcriptModels(path, sessionId) { return transcriptEvidence(path, sessionId).observedModels; }
|
|
70
95
|
|
|
96
|
+
export function collectScopedOutcome(protocol, taskId, sessionId, root, transcriptPath) {
|
|
97
|
+
const environment = observeEnvironment(root);
|
|
98
|
+
const task = protocol.verification.tasks.find(task => task.taskId === taskId);
|
|
99
|
+
if (!task) throw new Error('Unknown task');
|
|
100
|
+
const artifacts = [...new Set(task.checks.map(check => check.path))].map(path => {
|
|
101
|
+
try { return { path, content: readArtifact(root, path) }; }
|
|
102
|
+
catch (error) { return { path, content: null, error: error.message }; }
|
|
103
|
+
});
|
|
104
|
+
return { externalSessionId: sessionId, projectPath: root, environment, ...transcriptEvidence(transcriptPath, sessionId), artifacts, commands: task.checks.filter(check => check.kind === 'command_exit').map(check => runRepositoryCheck(root, check)) };
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
export async function runScopedHook(mode, sessionId, root, model, transcriptPath) {
|
|
72
108
|
const id = process.env.RULEMETRIC_SCOPED_CONFIRMATION_ID;
|
|
73
109
|
const taskId = process.env.RULEMETRIC_SCOPED_TASK_ID;
|
|
@@ -79,28 +115,41 @@ export async function runScopedHook(mode, sessionId, root, model, transcriptPath
|
|
|
79
115
|
const response = await fetch(`${api}/api${path}`, { method: body ? 'POST' : 'GET',
|
|
80
116
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
81
117
|
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000) });
|
|
82
|
-
if (!response.ok)
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const detail = await response.text();
|
|
120
|
+
throw new Error(`Scoped ${mode} refused (${response.status}): ${detail.slice(0, 1000)}`);
|
|
121
|
+
}
|
|
83
122
|
return response.json();
|
|
84
123
|
};
|
|
85
124
|
const { confirmation } = await request(`/scoped-live-confirmations/${id}/contract`);
|
|
86
125
|
const protocol = confirmation.protocol;
|
|
87
126
|
const environment = protocol.verification ? observeEnvironment(root) : undefined;
|
|
88
127
|
if (mode === 'start') {
|
|
89
|
-
|
|
90
|
-
|
|
128
|
+
if (!model && protocol.research?.enrollment === 'prospective-launches') {
|
|
129
|
+
// SessionStart's model is optional. A requested model is not an observed
|
|
130
|
+
// outcome: retain the launch pin and verify actual transcript models later.
|
|
131
|
+
const requested = process.env.RULEMETRIC_SCOPED_REQUESTED_MODEL;
|
|
132
|
+
if (requested && requested === protocol.model) model = requested;
|
|
133
|
+
else {
|
|
134
|
+
// Compatibility for already-queued launches from workers without the pin env.
|
|
135
|
+
const jobs = await request('/launch-jobs?limit=100');
|
|
136
|
+
const launch = jobs.find(job => job.tool === 'claude_code' && ['claimed', 'running'].includes(job.status)
|
|
137
|
+
&& job.config?.projectPath === root && job.config?.scopedConfirmation?.id === id
|
|
138
|
+
&& job.config?.scopedConfirmation?.taskId === taskId && job.config?.model === protocol.model);
|
|
139
|
+
model = launch?.config.model;
|
|
140
|
+
}
|
|
141
|
+
if (!model) throw new Error('SessionStart omitted model and no matching claimed launch model is available');
|
|
142
|
+
process.stderr.write('[rulemetric] Enrollment uses the requested launch model; actual model remains subject to transcript verification.\n');
|
|
143
|
+
}
|
|
144
|
+
const configurationHash = environment ? hash(JSON.stringify({ harnessVersion: environment.harnessVersion, repositoryRevision: protocol.research?.enrollment === 'prospective-launches' ? protocol.verification.environment.repositoryRevision : environment.repositoryRevision, contextHashes: environment.contextHashes })) : process.env.RULEMETRIC_SCOPED_CONFIGURATION_HASH;
|
|
145
|
+
const rendered = await request(`/scoped-live-confirmations/${id}/hook-render`, { taskId, externalSessionId: sessionId, model, projectPath: root, configurationHash, environment, collectorVersion: 2 });
|
|
91
146
|
const content = Buffer.from(rendered.contentBase64, 'base64');
|
|
92
147
|
if (hash(content) !== rendered.deliveryHash) throw new Error('Scoped delivery hash mismatch');
|
|
93
148
|
// No unhashed headings or wrappers are added to the treatment.
|
|
94
149
|
await new Promise((resolve, reject) => process.stdout.write(content, error => error ? reject(error) : resolve()));
|
|
95
150
|
await request(`/scoped-live-confirmations/${id}/assignments/${rendered.assignment.id}/delivery`, { deliveryHash: rendered.deliveryHash });
|
|
96
151
|
} 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 });
|
|
152
|
+
await request(`/scoped-live-confirmations/${id}/verify`, collectScopedOutcome(protocol, taskId, sessionId, root, transcriptPath));
|
|
104
153
|
// A non-final task legitimately returns 409; its receipt is already durable.
|
|
105
154
|
await request(`/scoped-live-confirmations/${id}/decide`, {}).catch(() => {});
|
|
106
155
|
}
|