yam-flow 2.8.0 → 2.8.2
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/AGENTS.md +4 -0
- package/CHANGELOG.md +22 -0
- package/COMMANDS.md +17 -2
- package/README.md +5 -1
- package/dist/bin/yam.js +162 -42
- package/dist/lib/design-production.d.ts +6 -6
- package/dist/lib/external-updates.d.ts +6 -1
- package/dist/lib/external-updates.js +93 -6
- package/dist/lib/hook-readiness.d.ts +54 -0
- package/dist/lib/hook-readiness.js +259 -0
- package/dist/lib/memory-records.d.ts +29 -0
- package/dist/lib/memory-records.js +92 -0
- package/dist/lib/security-check.d.ts +98 -0
- package/dist/lib/security-check.js +262 -0
- package/dist/lib/security-integration.d.ts +8 -0
- package/dist/lib/security-integration.js +428 -0
- package/dist/lib/verification-closure.d.ts +1 -1
- package/package.json +10 -3
- package/references/final-report.md +2 -0
- package/references/security-check.md +131 -0
- package/references/study-note.md +6 -0
- package/skills/deep/SKILL.md +4 -0
- package/skills/mission/SKILL.md +4 -0
- package/skills/quick/SKILL.md +4 -0
- package/skills/ueye/SKILL.md +4 -0
- package/src/bin/yam.ts +142 -39
- package/src/lib/external-updates.ts +87 -6
- package/src/lib/hook-readiness.ts +215 -0
- package/src/lib/memory-records.ts +109 -0
- package/src/lib/security-check.ts +184 -0
- package/src/lib/security-integration.ts +299 -0
- package/templates/security-check-note.md +42 -0
- package/templates/security-gate.yml +44 -0
- package/yam.manifest.json +1 -1
|
@@ -22,7 +22,7 @@ export async function checkExternalUpdates(currentYamVersion, dependencies = {})
|
|
|
22
22
|
checkScrapling(deps),
|
|
23
23
|
checkInsaneSearch(deps)
|
|
24
24
|
]);
|
|
25
|
-
const failed = checks.filter((item) => item.status === 'check_failed');
|
|
25
|
+
const failed = checks.filter((item) => item.status === 'check_failed' || item.status === 'pending_distribution');
|
|
26
26
|
return {
|
|
27
27
|
schema: 'yam.external-update-check.v1',
|
|
28
28
|
generated_at: deps.now().toISOString(),
|
|
@@ -212,6 +212,7 @@ async function resolveDependencies(input) {
|
|
|
212
212
|
return {
|
|
213
213
|
run,
|
|
214
214
|
fetchJson: input.fetchJson || defaultFetchJson,
|
|
215
|
+
probeArtifact: input.probeArtifact || defaultProbeArtifact,
|
|
215
216
|
now: input.now || (() => new Date()),
|
|
216
217
|
homeDir,
|
|
217
218
|
env,
|
|
@@ -267,18 +268,57 @@ async function acquireApplyLock(deps) {
|
|
|
267
268
|
return handle;
|
|
268
269
|
}
|
|
269
270
|
async function checkYam(currentYamVersion, deps) {
|
|
271
|
+
let pending;
|
|
270
272
|
try {
|
|
271
273
|
const data = await deps.fetchJson(YAM_REGISTRY_URL);
|
|
272
274
|
const latest = requireStableVersion(data?.version, 'yam registry version');
|
|
273
275
|
const installed = requireStableVersion(currentYamVersion, 'installed yam version');
|
|
274
276
|
const integrity = requireIntegrityValue(data?.dist?.integrity, 'yam registry integrity');
|
|
275
277
|
const revision = requireGitRevision(data?.gitHead, 'yam registry gitHead');
|
|
278
|
+
if (data?.name !== 'yam-flow')
|
|
279
|
+
throw new Error('yam registry package name mismatch');
|
|
280
|
+
const tarball = requireYamTarball(data?.dist?.tarball, latest);
|
|
281
|
+
const exactUrl = `${NPM_REGISTRY}yam-flow/${latest}`;
|
|
282
|
+
pending = {
|
|
283
|
+
...componentCheck('yam', installed, latest, 'npm:yam-flow'),
|
|
284
|
+
status: 'pending_distribution',
|
|
285
|
+
error: 'pending_distribution: exact-version metadata or tarball returned HTTP 404; retry the explicit update later',
|
|
286
|
+
source_receipt: {
|
|
287
|
+
canonical_url: YAM_REGISTRY_URL,
|
|
288
|
+
retrieved_at: deps.now().toISOString(),
|
|
289
|
+
access_path: 'npm latest observed; exact-version distribution readiness incomplete',
|
|
290
|
+
version: latest,
|
|
291
|
+
revision,
|
|
292
|
+
integrity: [integrity]
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
let exact;
|
|
296
|
+
try {
|
|
297
|
+
exact = await deps.fetchJson(exactUrl);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (error instanceof RegistryHttpError && error.status === 404)
|
|
301
|
+
return pending;
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
if (exact?.name !== 'yam-flow' || data?.name !== 'yam-flow'
|
|
305
|
+
|| requireStableVersion(exact?.version, 'yam exact registry version') !== latest
|
|
306
|
+
|| requireIntegrityValue(exact?.dist?.integrity, 'yam exact registry integrity') !== integrity
|
|
307
|
+
|| requireGitRevision(exact?.gitHead, 'yam exact registry gitHead') !== revision
|
|
308
|
+
|| requireYamTarball(exact?.dist?.tarball, latest) !== tarball) {
|
|
309
|
+
throw new Error('yam latest and exact-version registry metadata disagree');
|
|
310
|
+
}
|
|
311
|
+
const artifactStatus = await deps.probeArtifact(tarball);
|
|
312
|
+
if (artifactStatus === 404)
|
|
313
|
+
return pending;
|
|
314
|
+
if (artifactStatus !== 200)
|
|
315
|
+
throw new Error(`yam tarball availability check failed: HTTP ${artifactStatus}`);
|
|
276
316
|
return {
|
|
277
317
|
...componentCheck('yam', installed, latest, 'npm:yam-flow'),
|
|
278
318
|
source_receipt: {
|
|
279
319
|
canonical_url: YAM_REGISTRY_URL,
|
|
280
320
|
retrieved_at: deps.now().toISOString(),
|
|
281
|
-
access_path: 'npm
|
|
321
|
+
access_path: 'npm latest + exact-version metadata + bounded no-redirect tarball HEAD (availability only)',
|
|
282
322
|
version: latest,
|
|
283
323
|
revision,
|
|
284
324
|
integrity: [integrity]
|
|
@@ -446,7 +486,7 @@ function failedCheck(component, installed, source, error) {
|
|
|
446
486
|
}
|
|
447
487
|
async function applyYam(currentYamVersion, deps) {
|
|
448
488
|
const check = await checkYam(currentYamVersion, deps);
|
|
449
|
-
if (check.status === 'check_failed')
|
|
489
|
+
if (check.status === 'check_failed' || check.status === 'pending_distribution')
|
|
450
490
|
return persistFailureFromCheck(check, deps);
|
|
451
491
|
if (!check.update_available) {
|
|
452
492
|
return persistReceipt(baseReceipt(check, 'up_to_date', [], {
|
|
@@ -1378,15 +1418,62 @@ async function defaultRun(command, args, options = {}) {
|
|
|
1378
1418
|
}
|
|
1379
1419
|
async function defaultFetchJson(url) {
|
|
1380
1420
|
const response = await fetch(url, {
|
|
1421
|
+
redirect: 'error',
|
|
1422
|
+
credentials: 'omit',
|
|
1381
1423
|
headers: {
|
|
1382
1424
|
accept: 'application/json',
|
|
1383
1425
|
'user-agent': 'yam-flow external-update-check'
|
|
1384
1426
|
},
|
|
1385
1427
|
signal: AbortSignal.timeout(30000)
|
|
1386
1428
|
});
|
|
1387
|
-
if (!response.ok)
|
|
1388
|
-
|
|
1389
|
-
|
|
1429
|
+
if (!response.ok) {
|
|
1430
|
+
await response.body?.cancel();
|
|
1431
|
+
throw new RegistryHttpError(response.status, url);
|
|
1432
|
+
}
|
|
1433
|
+
const reader = response.body?.getReader();
|
|
1434
|
+
if (!reader)
|
|
1435
|
+
throw new Error(`empty registry response for ${url}`);
|
|
1436
|
+
const chunks = [];
|
|
1437
|
+
let size = 0;
|
|
1438
|
+
try {
|
|
1439
|
+
while (true) {
|
|
1440
|
+
const chunk = await reader.read();
|
|
1441
|
+
if (chunk.done)
|
|
1442
|
+
break;
|
|
1443
|
+
size += chunk.value.byteLength;
|
|
1444
|
+
if (size > 1024 * 1024)
|
|
1445
|
+
throw new Error('registry metadata exceeds 1 MiB limit');
|
|
1446
|
+
chunks.push(chunk.value);
|
|
1447
|
+
}
|
|
1448
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
1449
|
+
}
|
|
1450
|
+
finally {
|
|
1451
|
+
await reader.cancel().catch(() => undefined);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
export class RegistryHttpError extends Error {
|
|
1455
|
+
status;
|
|
1456
|
+
constructor(status, url) {
|
|
1457
|
+
super(`HTTP ${status} for ${url}`);
|
|
1458
|
+
this.status = status;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
function requireYamTarball(value, version) {
|
|
1462
|
+
const expected = `${NPM_REGISTRY}yam-flow/-/yam-flow-${version}.tgz`;
|
|
1463
|
+
if (value !== expected)
|
|
1464
|
+
throw new Error('yam tarball must be the exact-version artifact on the official npm registry');
|
|
1465
|
+
return expected;
|
|
1466
|
+
}
|
|
1467
|
+
async function defaultProbeArtifact(url) {
|
|
1468
|
+
const response = await fetch(url, {
|
|
1469
|
+
method: 'HEAD',
|
|
1470
|
+
redirect: 'error',
|
|
1471
|
+
credentials: 'omit',
|
|
1472
|
+
headers: { 'user-agent': 'yam-flow external-update-check' },
|
|
1473
|
+
signal: AbortSignal.timeout(10000)
|
|
1474
|
+
});
|
|
1475
|
+
await response.body?.cancel();
|
|
1476
|
+
return response.status;
|
|
1390
1477
|
}
|
|
1391
1478
|
function requireStableVersion(value, label) {
|
|
1392
1479
|
const version = String(value || '').trim();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Select an upgrade-stable alias only when it resolves to the running Node binary. */
|
|
2
|
+
export declare function selectHookNode(executable?: string, searchPath?: string): Promise<{
|
|
3
|
+
executable: string;
|
|
4
|
+
canonical: string;
|
|
5
|
+
selection: string;
|
|
6
|
+
dev: number;
|
|
7
|
+
ino: number;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function summarizeHookDiscovery(value: unknown, cwd: string, commands: Record<string, string[]>, configPath?: string): {
|
|
10
|
+
state: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
profiles: {};
|
|
13
|
+
warning_count?: undefined;
|
|
14
|
+
error_count?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
state: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
profiles: {
|
|
19
|
+
[k: string]: {
|
|
20
|
+
state: string;
|
|
21
|
+
enabled_trusted: number;
|
|
22
|
+
eligible_event_coverage: boolean;
|
|
23
|
+
discovered_count: number;
|
|
24
|
+
trust_statuses: string[];
|
|
25
|
+
execution_observed: boolean;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
warning_count: number;
|
|
29
|
+
error_count: number;
|
|
30
|
+
};
|
|
31
|
+
/** Opt-in discovery in a new app-server process, not the active desktop session. */
|
|
32
|
+
export declare function probeHookDiscovery(options: {
|
|
33
|
+
executable: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
commands: Record<string, string[]>;
|
|
36
|
+
configPath?: string;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}): Promise<Record<string, unknown>>;
|
|
39
|
+
export declare function recordHookObservation(cwd: string, profile: string, event: string, entrypoint: string): Promise<void>;
|
|
40
|
+
export declare function inspectHookObservation(cwd: string, profile: string, entrypoint: string): Promise<{
|
|
41
|
+
state: string;
|
|
42
|
+
reason: string;
|
|
43
|
+
truth_status: string;
|
|
44
|
+
host_authenticated: boolean;
|
|
45
|
+
observed_at?: undefined;
|
|
46
|
+
event?: undefined;
|
|
47
|
+
} | {
|
|
48
|
+
state: string;
|
|
49
|
+
observed_at: any;
|
|
50
|
+
event: any;
|
|
51
|
+
host_authenticated: boolean;
|
|
52
|
+
truth_status: string;
|
|
53
|
+
reason: string;
|
|
54
|
+
}>;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import fsp from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
const MAX_BYTES = 256 * 1024;
|
|
8
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
9
|
+
/** Select an upgrade-stable alias only when it resolves to the running Node binary. */
|
|
10
|
+
export async function selectHookNode(executable = process.execPath, searchPath = process.env.PATH || '') {
|
|
11
|
+
const real = await fsp.realpath(executable);
|
|
12
|
+
const running = await fsp.stat(real);
|
|
13
|
+
const candidates = searchPath.split(path.delimiter).filter((dir) => path.isAbsolute(dir))
|
|
14
|
+
.map((dir) => path.join(dir, process.platform === 'win32' ? 'node.exe' : 'node'));
|
|
15
|
+
for (const candidate of candidates) {
|
|
16
|
+
try {
|
|
17
|
+
const canonical = await fsp.realpath(candidate);
|
|
18
|
+
const stat = await fsp.stat(canonical);
|
|
19
|
+
await fsp.access(candidate, fs.constants.X_OK);
|
|
20
|
+
if (stat.isFile() && stat.dev === running.dev && stat.ino === running.ino && canonical === real) {
|
|
21
|
+
return { executable: candidate, canonical, selection: 'verified_runtime_alias', dev: stat.dev, ino: stat.ino };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch { /* An unverified PATH candidate is never executed. */ }
|
|
25
|
+
}
|
|
26
|
+
return { executable: real, canonical: real, selection: 'current_runtime_fallback', dev: running.dev, ino: running.ino };
|
|
27
|
+
}
|
|
28
|
+
export function summarizeHookDiscovery(value, cwd, commands, configPath) {
|
|
29
|
+
const input = value;
|
|
30
|
+
if (!Array.isArray(input?.data))
|
|
31
|
+
return { state: 'unknown', reason: 'unsupported_response_schema', profiles: {} };
|
|
32
|
+
const entry = input.data.find((row) => row.cwd === cwd);
|
|
33
|
+
if (!entry || !Array.isArray(entry.hooks) || !Array.isArray(entry.errors) || !Array.isArray(entry.warnings)) {
|
|
34
|
+
return { state: 'unknown', reason: 'missing_cwd_or_unsupported_response', profiles: {} };
|
|
35
|
+
}
|
|
36
|
+
const profiles = Object.fromEntries(Object.entries(commands).map(([profile, expected]) => {
|
|
37
|
+
const matches = entry.hooks.filter((hook) => hook.handlerType === 'command' && expected.includes(String(hook.command))
|
|
38
|
+
&& (!configPath || hook.sourcePath === configPath));
|
|
39
|
+
const approved = matches.filter((hook) => hook.enabled === true && ['trusted', 'managed'].includes(String(hook.trustStatus)));
|
|
40
|
+
const expectedEvents = profile === 'study-note' ? ['userPromptSubmit', 'stop'] : ['userPromptSubmit'];
|
|
41
|
+
return [profile, { state: matches.length ? 'discovered' : 'not_discovered', enabled_trusted: approved.length,
|
|
42
|
+
eligible_event_coverage: expectedEvents.every((event) => approved.filter((hook) => hook.eventName === event).length === 1),
|
|
43
|
+
discovered_count: matches.length, trust_statuses: [...new Set(matches.map((hook) => String(hook.trustStatus)))],
|
|
44
|
+
execution_observed: false }];
|
|
45
|
+
}));
|
|
46
|
+
return { state: entry.errors.length ? 'partial' : 'queried', reason: entry.errors.length ? 'host_discovery_errors' : '',
|
|
47
|
+
profiles, warning_count: entry.warnings.length, error_count: entry.errors.length };
|
|
48
|
+
}
|
|
49
|
+
/** Opt-in discovery in a new app-server process, not the active desktop session. */
|
|
50
|
+
export async function probeHookDiscovery(options) {
|
|
51
|
+
const timeoutMs = Math.min(Math.max(options.timeoutMs || 5000, 100), 10000);
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let finished = false;
|
|
54
|
+
let stopping = false;
|
|
55
|
+
let failure = '';
|
|
56
|
+
let result;
|
|
57
|
+
let buffer = '';
|
|
58
|
+
let total = 0;
|
|
59
|
+
let timer;
|
|
60
|
+
let killTimer;
|
|
61
|
+
const child = spawn(options.executable, ['app-server'], { cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
|
|
62
|
+
const stop = (reason = '') => {
|
|
63
|
+
if (stopping)
|
|
64
|
+
return;
|
|
65
|
+
stopping = true;
|
|
66
|
+
failure = reason;
|
|
67
|
+
child.stdin.end();
|
|
68
|
+
child.kill('SIGTERM');
|
|
69
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 500);
|
|
70
|
+
};
|
|
71
|
+
const send = (value) => { if (!stopping)
|
|
72
|
+
child.stdin.write(`${JSON.stringify(value)}\n`); };
|
|
73
|
+
child.stdin.on('error', () => stop('protocol_stdin_failed'));
|
|
74
|
+
child.on('error', () => { failure = 'host_unavailable'; });
|
|
75
|
+
child.stderr.on('data', (chunk) => { total += chunk.length; if (total > MAX_BYTES)
|
|
76
|
+
stop('output_limit'); });
|
|
77
|
+
child.stdout.on('data', (chunk) => {
|
|
78
|
+
total += chunk.length;
|
|
79
|
+
if (total > MAX_BYTES)
|
|
80
|
+
return stop('output_limit');
|
|
81
|
+
buffer += chunk.toString('utf8');
|
|
82
|
+
let index;
|
|
83
|
+
while (!stopping && (index = buffer.indexOf('\n')) >= 0) {
|
|
84
|
+
const line = buffer.slice(0, index);
|
|
85
|
+
buffer = buffer.slice(index + 1);
|
|
86
|
+
let message;
|
|
87
|
+
try {
|
|
88
|
+
message = JSON.parse(line);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
stop('invalid_protocol_json');
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (message.id === 0) {
|
|
95
|
+
if (message.error) {
|
|
96
|
+
stop('initialization_rejected');
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
send({ method: 'initialized', params: {} });
|
|
100
|
+
send({ method: 'hooks/list', id: 1, params: { cwds: [options.cwd] } });
|
|
101
|
+
}
|
|
102
|
+
else if (message.id === 1) {
|
|
103
|
+
if (message.error) {
|
|
104
|
+
stop('hooks_list_unsupported_or_rejected');
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
result = summarizeHookDiscovery(message.result, options.cwd, options.commands, options.configPath);
|
|
108
|
+
stop();
|
|
109
|
+
}
|
|
110
|
+
else if (message.id != null && message.method) {
|
|
111
|
+
stop('unexpected_server_request');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
child.on('close', (_code, signal) => {
|
|
116
|
+
if (finished)
|
|
117
|
+
return;
|
|
118
|
+
finished = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
clearTimeout(killTimer);
|
|
121
|
+
resolve({ ...(result || { state: 'unknown', profiles: {} }), ...(failure ? { state: 'unknown', reason: failure } : {}),
|
|
122
|
+
host_context: 'separate_app_server_probe', active_session_loaded: 'not_measured',
|
|
123
|
+
execution_observed: false, process_exit_observed: true, termination_signal: signal || null,
|
|
124
|
+
truth_status: 'partial', reason: failure || (result ? result.reason : 'host_exited_before_response') });
|
|
125
|
+
});
|
|
126
|
+
timer = setTimeout(() => stop('probe_timeout'), timeoutMs);
|
|
127
|
+
send({ method: 'initialize', id: 0, params: { clientInfo: { name: 'yam_hook_probe', version: '1' }, capabilities: { experimentalApi: true } } });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function safeObservationDirectory(cwd, create) {
|
|
131
|
+
const root = await fsp.realpath(cwd);
|
|
132
|
+
let current = root;
|
|
133
|
+
const identities = [];
|
|
134
|
+
for (const part of ['', '.yam', 'hooks']) {
|
|
135
|
+
if (part)
|
|
136
|
+
current = path.join(current, part);
|
|
137
|
+
if (create && part)
|
|
138
|
+
await fsp.mkdir(current, { mode: 0o700 }).catch((error) => { if (error.code !== 'EEXIST')
|
|
139
|
+
throw error; });
|
|
140
|
+
const stat = await fsp.lstat(current);
|
|
141
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
142
|
+
throw new Error('unsafe_observation_directory');
|
|
143
|
+
identities.push({ target: current, dev: stat.dev, ino: stat.ino });
|
|
144
|
+
}
|
|
145
|
+
return { directory: current, identities };
|
|
146
|
+
}
|
|
147
|
+
async function checkParents(identities) {
|
|
148
|
+
for (const identity of identities) {
|
|
149
|
+
const stat = await fsp.lstat(identity.target);
|
|
150
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== identity.dev || stat.ino !== identity.ino)
|
|
151
|
+
throw new Error('observation_parent_changed');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function configurationDigest(cwd) {
|
|
155
|
+
const paths = [path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json'), path.join(await fsp.realpath(cwd), '.codex', 'hooks.json')];
|
|
156
|
+
const values = [];
|
|
157
|
+
for (const target of paths) {
|
|
158
|
+
try {
|
|
159
|
+
const before = await fsp.lstat(target);
|
|
160
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_BYTES)
|
|
161
|
+
throw new Error('unsafe_hook_configuration');
|
|
162
|
+
const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
|
|
163
|
+
try {
|
|
164
|
+
const stat = await handle.stat();
|
|
165
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > MAX_BYTES)
|
|
166
|
+
throw new Error('hook_configuration_changed');
|
|
167
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
168
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
169
|
+
if (bytesRead > MAX_BYTES)
|
|
170
|
+
throw new Error('hook_configuration_limit');
|
|
171
|
+
values.push(sha(buffer.subarray(0, bytesRead).toString('utf8')));
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await handle.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== 'ENOENT')
|
|
179
|
+
throw error;
|
|
180
|
+
values.push('absent');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return sha(JSON.stringify(values));
|
|
184
|
+
}
|
|
185
|
+
export async function recordHookObservation(cwd, profile, event, entrypoint) {
|
|
186
|
+
if (!['lite', 'study-note'].includes(profile) || !['UserPromptSubmit', 'Stop'].includes(event))
|
|
187
|
+
throw new Error('unsupported_observation');
|
|
188
|
+
const { directory, identities } = await safeObservationDirectory(cwd, true);
|
|
189
|
+
const value = { schema: 'yam.hook-observation.v1', profile, event, observed_at: new Date().toISOString(),
|
|
190
|
+
cwd_digest: sha(await fsp.realpath(cwd)), entrypoint_digest: sha(await fsp.readFile(entrypoint, 'utf8')),
|
|
191
|
+
configuration_digest: await configurationDigest(cwd),
|
|
192
|
+
host_authenticated: false, evidence_kind: 'local_entrypoint_invocation' };
|
|
193
|
+
const target = path.join(directory, `${profile}.json`);
|
|
194
|
+
const noFollow = process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0;
|
|
195
|
+
const before = await fsp.lstat(target).catch((error) => { if (error.code === 'ENOENT')
|
|
196
|
+
return null; throw error; });
|
|
197
|
+
if (before && (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1))
|
|
198
|
+
throw new Error('unsafe_observation_file');
|
|
199
|
+
const handle = await fsp.open(target, fs.constants.O_WRONLY | noFollow | (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL), 0o600);
|
|
200
|
+
try {
|
|
201
|
+
const stat = await handle.stat();
|
|
202
|
+
const pathname = await fsp.lstat(target);
|
|
203
|
+
if (!stat.isFile() || stat.nlink !== 1 || pathname.isSymbolicLink() || pathname.dev !== stat.dev || pathname.ino !== stat.ino
|
|
204
|
+
|| (before && (before.dev !== stat.dev || before.ino !== stat.ino)))
|
|
205
|
+
throw new Error('unsafe_observation_file');
|
|
206
|
+
await checkParents(identities);
|
|
207
|
+
await handle.chmod(0o600);
|
|
208
|
+
await handle.truncate(0);
|
|
209
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`);
|
|
210
|
+
await checkParents(identities);
|
|
211
|
+
const after = await fsp.lstat(target);
|
|
212
|
+
if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino)
|
|
213
|
+
throw new Error('observation_identity_changed');
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await handle.close();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export async function inspectHookObservation(cwd, profile, entrypoint) {
|
|
220
|
+
try {
|
|
221
|
+
if (!['lite', 'study-note'].includes(profile))
|
|
222
|
+
throw new Error('unsupported_profile');
|
|
223
|
+
const { directory, identities } = await safeObservationDirectory(cwd, false);
|
|
224
|
+
const target = path.join(directory, `${profile}.json`);
|
|
225
|
+
const before = await fsp.lstat(target);
|
|
226
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || before.nlink !== 1)
|
|
227
|
+
throw new Error('unsafe_observation_file');
|
|
228
|
+
const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
|
|
229
|
+
let value;
|
|
230
|
+
try {
|
|
231
|
+
const stat = await handle.stat();
|
|
232
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > 4096)
|
|
233
|
+
throw new Error('observation_identity_changed');
|
|
234
|
+
const data = Buffer.alloc(4097);
|
|
235
|
+
const { bytesRead } = await handle.read(data, 0, data.length, 0);
|
|
236
|
+
if (bytesRead > 4096)
|
|
237
|
+
throw new Error('observation_limit');
|
|
238
|
+
value = JSON.parse(data.subarray(0, bytesRead).toString('utf8'));
|
|
239
|
+
await checkParents(identities);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
await handle.close();
|
|
243
|
+
}
|
|
244
|
+
const age = Date.now() - Date.parse(value.observed_at);
|
|
245
|
+
if (value.schema !== 'yam.hook-observation.v1' || value.profile !== profile || !['UserPromptSubmit', 'Stop'].includes(value.event)
|
|
246
|
+
|| value.host_authenticated !== false || value.evidence_kind !== 'local_entrypoint_invocation'
|
|
247
|
+
|| !Number.isFinite(age) || age < 0 || age > 24 * 60 * 60 * 1000
|
|
248
|
+
|| value.cwd_digest !== sha(await fsp.realpath(cwd)) || value.configuration_digest !== await configurationDigest(cwd)
|
|
249
|
+
|| value.entrypoint_digest !== sha(await fsp.readFile(entrypoint, 'utf8'))) {
|
|
250
|
+
return { state: 'unknown', reason: 'stale_or_mismatched_observation', truth_status: 'partial', host_authenticated: false };
|
|
251
|
+
}
|
|
252
|
+
return { state: 'entrypoint_observed', observed_at: value.observed_at, event: value.event,
|
|
253
|
+
host_authenticated: false, truth_status: 'partial', reason: 'local_same_user_evidence_not_host_attestation' };
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return { state: 'not_observed', reason: error.code === 'ENOENT' ? 'no_observation' : 'observation_unreadable_or_unsafe',
|
|
257
|
+
host_authenticated: false, truth_status: 'partial' };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
export type MemoryReadState = 'missing' | 'empty' | 'complete' | 'unreadable' | 'invalid' | 'incomplete';
|
|
3
|
+
export interface MemoryRecord {
|
|
4
|
+
schemaVersion: number;
|
|
5
|
+
id: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
status: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
evidence?: string;
|
|
10
|
+
action?: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface MemoryReadReport {
|
|
16
|
+
schema: 'yam.memory-read.v1';
|
|
17
|
+
state: MemoryReadState;
|
|
18
|
+
complete: boolean;
|
|
19
|
+
records: MemoryRecord[];
|
|
20
|
+
diagnostics: Array<{
|
|
21
|
+
operation: string;
|
|
22
|
+
code: string;
|
|
23
|
+
record_index?: number;
|
|
24
|
+
}>;
|
|
25
|
+
diagnostics_truncated: boolean;
|
|
26
|
+
}
|
|
27
|
+
export type MemoryReadIo = Pick<typeof fsp, 'access' | 'readdir' | 'readFile'>;
|
|
28
|
+
/** Diagnostics intentionally contain no filesystem paths, record text, or exception messages. */
|
|
29
|
+
export declare function readMemoryDirectory(recordsDir: string, io?: MemoryReadIo): Promise<MemoryReadReport>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const kinds = new Set(['wrong_decision', 'repeat_mistake', 'direction_change', 'lesson', 'risk', 'command']);
|
|
4
|
+
const safeCodes = new Set(['ENOENT', 'EACCES', 'EPERM', 'EIO', 'ENOTDIR', 'EISDIR', 'EMFILE', 'ENFILE', 'ELOOP']);
|
|
5
|
+
function errorCode(error) {
|
|
6
|
+
const code = error?.code;
|
|
7
|
+
return typeof code === 'string' && safeCodes.has(code) ? code : 'READ_FAILED';
|
|
8
|
+
}
|
|
9
|
+
function isMemoryRecord(value) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
+
return false;
|
|
12
|
+
const r = value;
|
|
13
|
+
return r.schemaVersion === 1 && typeof r.id === 'string' && r.id.length > 0
|
|
14
|
+
&& typeof r.kind === 'string' && kinds.has(r.kind)
|
|
15
|
+
&& (r.status === 'active' || r.status === 'resolved')
|
|
16
|
+
&& typeof r.summary === 'string' && r.summary.trim().length > 0
|
|
17
|
+
&& ['evidence', 'action', 'source', 'createdAt', 'resolvedAt', 'resolution'].every((key) => r[key] === undefined || typeof r[key] === 'string');
|
|
18
|
+
}
|
|
19
|
+
/** Diagnostics intentionally contain no filesystem paths, record text, or exception messages. */
|
|
20
|
+
export async function readMemoryDirectory(recordsDir, io = fsp) {
|
|
21
|
+
const report = {
|
|
22
|
+
schema: 'yam.memory-read.v1', state: 'complete', complete: true,
|
|
23
|
+
records: [], diagnostics: [], diagnostics_truncated: false
|
|
24
|
+
};
|
|
25
|
+
const diagnose = (operation, code, record_index) => {
|
|
26
|
+
if (report.diagnostics.length < 20)
|
|
27
|
+
report.diagnostics.push({ operation, code, ...(record_index === undefined ? {} : { record_index }) });
|
|
28
|
+
else
|
|
29
|
+
report.diagnostics_truncated = true;
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
await io.access(recordsDir);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const code = errorCode(error);
|
|
36
|
+
report.state = code === 'ENOENT' ? 'missing' : 'unreadable';
|
|
37
|
+
report.complete = false;
|
|
38
|
+
diagnose('access', code);
|
|
39
|
+
return report;
|
|
40
|
+
}
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await io.readdir(recordsDir, { withFileTypes: true });
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
// An existing directory disappearing mid-read is incomplete, not an empty memory.
|
|
47
|
+
report.state = errorCode(error) === 'ENOENT' ? 'incomplete' : 'unreadable';
|
|
48
|
+
report.complete = false;
|
|
49
|
+
diagnose('readdir', errorCode(error));
|
|
50
|
+
return report;
|
|
51
|
+
}
|
|
52
|
+
let invalid = 0;
|
|
53
|
+
let unreadable = 0;
|
|
54
|
+
const candidates = entries.filter((entry) => entry.name.endsWith('.json')).sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
for (const [index, entry] of candidates.entries()) {
|
|
56
|
+
if (!entry.isFile()) {
|
|
57
|
+
unreadable += 1;
|
|
58
|
+
diagnose('record_type', 'NOT_REGULAR_FILE', index);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = await io.readFile(path.join(recordsDir, entry.name), 'utf8');
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
unreadable += 1;
|
|
67
|
+
diagnose('read', errorCode(error), index);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
let record;
|
|
71
|
+
try {
|
|
72
|
+
record = JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
invalid += 1;
|
|
76
|
+
diagnose('parse', 'INVALID_JSON', index);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!isMemoryRecord(record)) {
|
|
80
|
+
invalid += 1;
|
|
81
|
+
diagnose('validate', 'INVALID_RECORD', index);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
report.records.push(record);
|
|
85
|
+
}
|
|
86
|
+
report.records.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
|
|
87
|
+
report.complete = invalid + unreadable === 0;
|
|
88
|
+
report.state = report.complete ? (report.records.length ? 'complete' : 'empty')
|
|
89
|
+
: report.records.length || (invalid && unreadable) ? 'incomplete'
|
|
90
|
+
: unreadable ? 'unreadable' : 'invalid';
|
|
91
|
+
return report;
|
|
92
|
+
}
|