loop-pass-audit.mjs
358 lines 11.4 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Loop pass audit mirror store — append-only Knowtation adjunct (Phase 2G-e, OD-7).
3 *
4 * Scooling operational pass audit remains primary; this module mirrors pointer-only
5 * entries for cross-vault queries. Gated by LOOP_PASS_AUDIT_MIRROR_ENABLED (default off).
6 *
7 * @see scooling/docs/LOOP-ORCHESTRATOR-DISPATCH-CONTRACT-2G-e.md — OD-7 mirror shape
8 */
9
10 import fs from 'fs';
11 import path from 'path';
12 import { randomBytes } from 'crypto';
13 import {
14 BOUNDARY_POLICIES,
15 GRAPH_ID_RE,
16 LOOP_ID_RE,
17 } from './task-loop-store.mjs';
18 import { TASK_ID_RE } from './task-store.mjs';
19
20 export const LOOP_PASS_AUDIT_FILE = 'hub_loop_pass_audit.json';
21 export const LOOP_PASS_AUDIT_POLICY_FILE = 'hub_loop_pass_audit_policy.json';
22 export const LOOP_PASS_AUDIT_SCHEMA = 'knowtation.loop_pass_audit/v0';
23 export const LOOP_PASS_AUDIT_ID_PREFIX = 'lpau_';
24
25 export const PASS_ID_RE = /^pass_[a-z0-9_]{1,48}$/;
26
27 export const LOOP_PASS_OUTCOMES = /** @type {const} */ ([
28 'idle',
29 'scheduled',
30 'evaluating',
31 'running_agent_run',
32 'needs_human',
33 'stopped_at_boundary',
34 ]);
35
36 export const CONTEXT_REF_KINDS = /** @type {const} */ ([
37 'task',
38 'run',
39 'loop',
40 'calendar_event',
41 'note',
42 ]);
43
44 const LOOP_SCOPES = new Set(['personal', 'project', 'org']);
45 const SAFE_POINTER_REF_RE = /^[A-Za-z0-9._:#-]{1,256}$/;
46
47 /**
48 * @param {unknown} v
49 * @returns {boolean|null}
50 */
51 function envTriState(v) {
52 if (v === '1' || v === 'true') return true;
53 if (v === '0' || v === 'false') return false;
54 return null;
55 }
56
57 /**
58 * @param {string} dataDir
59 * @returns {object}
60 */
61 export function readLoopPassAuditPolicyFile(dataDir) {
62 if (!dataDir) return {};
63 const fp = path.join(dataDir, LOOP_PASS_AUDIT_POLICY_FILE);
64 try {
65 if (!fs.existsSync(fp)) return {};
66 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
67 return j && typeof j === 'object' ? j : {};
68 } catch {
69 return {};
70 }
71 }
72
73 /**
74 * @param {string} dataDir
75 * @returns {{ ok: true } | { ok: false, status: number, code: string, error: string }}
76 */
77 export function checkLoopPassAuditMirrorGate(dataDir) {
78 const policy = readLoopPassAuditPolicyFile(dataDir);
79 const fromPolicy = envTriState(policy.LOOP_PASS_AUDIT_MIRROR_ENABLED);
80 const fromEnv = envTriState(process.env.LOOP_PASS_AUDIT_MIRROR_ENABLED);
81 const enabled = fromPolicy ?? fromEnv ?? false;
82 if (!enabled) {
83 return {
84 ok: false,
85 status: 403,
86 code: 'LOOP_PASS_AUDIT_MIRROR_DISABLED',
87 error: 'Loop pass audit mirror is disabled',
88 };
89 }
90 return { ok: true };
91 }
92
93 /**
94 * @param {string} prefix
95 * @returns {string}
96 */
97 function randomToken(prefix) {
98 return prefix + randomBytes(12).toString('hex');
99 }
100
101 /**
102 * @param {string} dataDir
103 * @returns {{ vaults: Record<string, { entries: object[] }> }}
104 */
105 export function loadLoopPassAuditStore(dataDir) {
106 const fp = path.join(dataDir, LOOP_PASS_AUDIT_FILE);
107 if (!fs.existsSync(fp)) {
108 return { vaults: {} };
109 }
110 try {
111 const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
112 if (!raw || typeof raw !== 'object') return { vaults: {} };
113 if (!raw.vaults || typeof raw.vaults !== 'object') return { vaults: {} };
114 return /** @type {{ vaults: Record<string, { entries: object[] }> }} */ (raw);
115 } catch {
116 return { vaults: {} };
117 }
118 }
119
120 /**
121 * @param {string} dataDir
122 * @param {{ vaults: Record<string, { entries: object[] }> }} store
123 */
124 export function saveLoopPassAuditStore(dataDir, store) {
125 fs.mkdirSync(dataDir, { recursive: true });
126 const fp = path.join(dataDir, LOOP_PASS_AUDIT_FILE);
127 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
128 }
129
130 /**
131 * @param {unknown} record
132 * @returns {{ ok: true } | { ok: false, error: string }}
133 */
134 export function validateLoopPassAuditRecord(record) {
135 if (!record || typeof record !== 'object') {
136 return { ok: false, error: 'entry must be an object' };
137 }
138 const r = /** @type {Record<string, unknown>} */ (record);
139 if (r.schema !== LOOP_PASS_AUDIT_SCHEMA) {
140 return { ok: false, error: 'invalid schema' };
141 }
142 const auditId = typeof r.audit_id === 'string' ? r.audit_id.trim() : '';
143 if (!auditId.startsWith(LOOP_PASS_AUDIT_ID_PREFIX)) {
144 return { ok: false, error: 'invalid audit_id' };
145 }
146 const passId = typeof r.pass_id === 'string' ? r.pass_id.trim() : '';
147 if (!PASS_ID_RE.test(passId)) {
148 return { ok: false, error: 'invalid pass_id' };
149 }
150 const loopId = typeof r.loop_id === 'string' ? r.loop_id.trim() : '';
151 if (!LOOP_ID_RE.test(loopId)) {
152 return { ok: false, error: 'invalid loop_id' };
153 }
154 const instanceTaskId =
155 r.instance_task_id === null || r.instance_task_id === undefined
156 ? null
157 : typeof r.instance_task_id === 'string'
158 ? r.instance_task_id.trim()
159 : '';
160 if (instanceTaskId !== null && !TASK_ID_RE.test(instanceTaskId)) {
161 return { ok: false, error: 'invalid instance_task_id' };
162 }
163 const graphId =
164 r.graph_id === null || r.graph_id === undefined
165 ? null
166 : typeof r.graph_id === 'string'
167 ? r.graph_id.trim()
168 : '';
169 if (graphId !== null && !GRAPH_ID_RE.test(graphId)) {
170 return { ok: false, error: 'invalid graph_id' };
171 }
172 const outcome = typeof r.outcome === 'string' ? r.outcome.trim() : '';
173 if (!LOOP_PASS_OUTCOMES.includes(/** @type {typeof LOOP_PASS_OUTCOMES[number]} */ (outcome))) {
174 return { ok: false, error: 'invalid outcome' };
175 }
176 const boundaryPolicy =
177 typeof r.boundary_policy === 'string' ? r.boundary_policy.trim() : '';
178 if (
179 !BOUNDARY_POLICIES.includes(
180 /** @type {typeof BOUNDARY_POLICIES[number]} */ (boundaryPolicy),
181 )
182 ) {
183 return { ok: false, error: 'invalid boundary_policy' };
184 }
185 if (!Array.isArray(r.context_refs)) {
186 return { ok: false, error: 'context_refs must be an array' };
187 }
188 if (r.context_refs.length > 32) {
189 return { ok: false, error: 'context_refs too long' };
190 }
191 for (const ref of r.context_refs) {
192 if (!ref || typeof ref !== 'object') {
193 return { ok: false, error: 'invalid context_ref' };
194 }
195 const kind = typeof ref.kind === 'string' ? ref.kind.trim() : '';
196 const refVal = typeof ref.ref === 'string' ? ref.ref.trim() : '';
197 if (!CONTEXT_REF_KINDS.includes(/** @type {typeof CONTEXT_REF_KINDS[number]} */ (kind))) {
198 return { ok: false, error: 'invalid context_ref kind' };
199 }
200 if (!SAFE_POINTER_REF_RE.test(refVal)) {
201 return { ok: false, error: 'invalid context_ref ref' };
202 }
203 }
204 const scoolingRef =
205 typeof r.scooling_pass_audit_ref === 'string' ? r.scooling_pass_audit_ref.trim() : '';
206 if (!PASS_ID_RE.test(scoolingRef)) {
207 return { ok: false, error: 'invalid scooling_pass_audit_ref' };
208 }
209 const occurredAt = typeof r.occurred_at === 'string' ? r.occurred_at.trim() : '';
210 if (!occurredAt || Number.isNaN(Date.parse(occurredAt))) {
211 return { ok: false, error: 'invalid occurred_at' };
212 }
213 const vaultId = typeof r.vault_id === 'string' ? r.vault_id.trim() : '';
214 if (!vaultId) {
215 return { ok: false, error: 'invalid vault_id' };
216 }
217 const scope = typeof r.scope === 'string' ? r.scope.trim() : '';
218 if (!LOOP_SCOPES.has(scope)) {
219 return { ok: false, error: 'invalid scope' };
220 }
221 return { ok: true };
222 }
223
224 /**
225 * @param {number} status
226 * @param {string} code
227 * @param {string} error
228 */
229 function refuse(status, code, error) {
230 return { ok: false, status, error, code };
231 }
232
233 /**
234 * Normalize request body (snake_case wire) to stored record fields.
235 *
236 * @param {object} body
237 * @param {string} vaultId
238 */
239 function parseLoopPassAuditBody(body, vaultId) {
240 const passId =
241 typeof body.pass_id === 'string' && body.pass_id.trim()
242 ? body.pass_id.trim()
243 : typeof body.passId === 'string' && body.passId.trim()
244 ? body.passId.trim()
245 : '';
246 const loopId =
247 typeof body.loop_id === 'string' && body.loop_id.trim()
248 ? body.loop_id.trim()
249 : typeof body.loopId === 'string' && body.loopId.trim()
250 ? body.loopId.trim()
251 : '';
252 const instanceRaw = body.instance_task_id ?? body.instanceTaskId;
253 const instanceTaskId =
254 instanceRaw === null || instanceRaw === undefined
255 ? null
256 : typeof instanceRaw === 'string'
257 ? instanceRaw.trim()
258 : '';
259 const graphRaw = body.graph_id ?? body.graphId;
260 const graphId =
261 graphRaw === null || graphRaw === undefined
262 ? null
263 : typeof graphRaw === 'string'
264 ? graphRaw.trim()
265 : '';
266 const outcome =
267 typeof body.outcome === 'string' && body.outcome.trim() ? body.outcome.trim() : '';
268 const boundaryPolicy =
269 typeof body.boundary_policy === 'string' && body.boundary_policy.trim()
270 ? body.boundary_policy.trim()
271 : typeof body.boundaryPolicy === 'string' && body.boundaryPolicy.trim()
272 ? body.boundaryPolicy.trim()
273 : '';
274 const contextRefs = Array.isArray(body.context_refs)
275 ? body.context_refs
276 : Array.isArray(body.contextRefs)
277 ? body.contextRefs
278 : [];
279 const scoolingRef =
280 typeof body.scooling_pass_audit_ref === 'string' && body.scooling_pass_audit_ref.trim()
281 ? body.scooling_pass_audit_ref.trim()
282 : typeof body.scoolingPassAuditRef === 'string' && body.scoolingPassAuditRef.trim()
283 ? body.scoolingPassAuditRef.trim()
284 : passId;
285 const occurredAt =
286 typeof body.occurred_at === 'string' && body.occurred_at.trim()
287 ? body.occurred_at.trim()
288 : typeof body.occurredAt === 'string' && body.occurredAt.trim()
289 ? body.occurredAt.trim()
290 : '';
291 const scope =
292 typeof body.scope === 'string' && body.scope.trim() ? body.scope.trim() : 'personal';
293 const auditId =
294 typeof body.audit_id === 'string' && body.audit_id.trim()
295 ? body.audit_id.trim()
296 : typeof body.auditId === 'string' && body.auditId.trim()
297 ? body.auditId.trim()
298 : randomToken(LOOP_PASS_AUDIT_ID_PREFIX);
299
300 return {
301 schema: LOOP_PASS_AUDIT_SCHEMA,
302 audit_id: auditId,
303 pass_id: passId,
304 loop_id: loopId,
305 instance_task_id: instanceTaskId === '' ? null : instanceTaskId,
306 graph_id: graphId === '' ? null : graphId,
307 outcome,
308 boundary_policy: boundaryPolicy,
309 context_refs: contextRefs.map((ref) => {
310 if (!ref || typeof ref !== 'object') return { kind: 'note', ref: 'invalid' };
311 const row = /** @type {{ kind?: string, ref?: string }} */ (ref);
312 return {
313 kind: typeof row.kind === 'string' ? row.kind.trim() : '',
314 ref: typeof row.ref === 'string' ? row.ref.trim() : '',
315 };
316 }),
317 scooling_pass_audit_ref: scoolingRef,
318 occurred_at: occurredAt,
319 vault_id: vaultId,
320 scope,
321 };
322 }
323
324 /**
325 * Append loop pass audit mirror entry (idempotent on pass_id per vault).
326 *
327 * @param {{
328 * dataDir: string,
329 * vaultId: string,
330 * body: object,
331 * }} input
332 */
333 export function handleLoopPassAuditAppendRequest(input) {
334 const gate = checkLoopPassAuditMirrorGate(input.dataDir);
335 if (!gate.ok) return gate;
336
337 const body = input.body && typeof input.body === 'object' ? input.body : {};
338 const record = parseLoopPassAuditBody(body, input.vaultId);
339
340 const validation = validateLoopPassAuditRecord(record);
341 if (!validation.ok) {
342 return refuse(400, 'BAD_REQUEST', validation.error);
343 }
344
345 const store = loadLoopPassAuditStore(input.dataDir);
346 if (!store.vaults[input.vaultId]) {
347 store.vaults[input.vaultId] = { entries: [] };
348 }
349 const entries = store.vaults[input.vaultId].entries;
350 const existing = entries.find((e) => e && e.pass_id === record.pass_id);
351 if (existing) {
352 return { ok: true, payload: existing, idempotent: true };
353 }
354
355 entries.push(record);
356 saveLoopPassAuditStore(input.dataDir, store);
357 return { ok: true, payload: record, idempotent: false };
358 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago