atris 3.57.4 → 3.58.6
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/README.md +12 -1
- package/atris/skills/clawhub/member-runtime/SKILL.md +9 -9
- package/atris/skills/create-member/SKILL.md +16 -13
- package/atris/skills/design/SKILL.md +27 -4
- package/atris/skills/engines/SKILL.md +55 -12
- package/atris/skills/x-search/SKILL.md +8 -6
- package/atris/skills/youtube/SKILL.md +66 -21
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris/team/executor/MEMBER.md +3 -3
- package/atris/team/launcher/MEMBER.md +4 -4
- package/atris/team/navigator/MEMBER.md +3 -3
- package/atris/team/researcher/MEMBER.md +2 -2
- package/atris/team/validator/MEMBER.md +1 -1
- package/atris.md +14 -0
- package/ax +153 -23
- package/bin/atris.js +78 -30
- package/commands/aeo.js +4 -4
- package/commands/agents.js +1 -1
- package/commands/align.js +12 -12
- package/commands/analytics.js +3 -3
- package/commands/app.js +5 -5
- package/commands/ask.js +175 -0
- package/commands/auth.js +59 -31
- package/commands/autoland.js +29 -5
- package/commands/autopilot-front.js +6 -6
- package/commands/autopilot.js +90 -90
- package/commands/avail.js +5 -5
- package/commands/bench.js +63 -1
- package/commands/browse.js +2 -2
- package/commands/business.js +12 -12
- package/commands/card.js +2 -2
- package/commands/clean.js +13 -13
- package/commands/close.js +3 -3
- package/commands/compile.js +13 -13
- package/commands/computer.js +94 -27
- package/commands/console.js +5 -5
- package/commands/decide.js +1 -1
- package/commands/deck.js +9 -9
- package/commands/drive.js +4 -4
- package/commands/engine.js +173 -19
- package/commands/errors.js +3 -3
- package/commands/experiments.js +18 -1
- package/commands/feed.js +2 -2
- package/commands/feedback.js +4 -4
- package/commands/fleet.js +12 -12
- package/commands/game.js +1 -1
- package/commands/guide.js +49 -0
- package/commands/improve.js +19 -17
- package/commands/init.js +67 -19
- package/commands/interview.js +7 -7
- package/commands/land.js +40 -15
- package/commands/learn.js +216 -50
- package/commands/lesson.js +2 -2
- package/commands/log.js +1 -1
- package/commands/loops.js +4 -4
- package/commands/member.js +75 -50
- package/commands/mission.js +90 -82
- package/commands/one-lap.js +1 -1
- package/commands/pack.js +74 -8
- package/commands/plugin.js +6 -6
- package/commands/probe.js +9 -9
- package/commands/pull.js +35 -35
- package/commands/pulse.js +1 -1
- package/commands/push.js +21 -21
- package/commands/rainmaker.js +1 -1
- package/commands/recap.js +7 -7
- package/commands/reel.js +2 -2
- package/commands/release.js +3 -3
- package/commands/report.js +1 -1
- package/commands/review.js +5 -5
- package/commands/revisions.js +1 -1
- package/commands/run.js +1 -1
- package/commands/scout.js +3 -3
- package/commands/security-review.js +7 -7
- package/commands/serve.js +9 -9
- package/commands/setup.js +1 -1
- package/commands/sign.js +4 -4
- package/commands/signup.js +4 -4
- package/commands/site.js +8 -3
- package/commands/skill.js +6 -6
- package/commands/slop.js +18 -18
- package/commands/social.js +3 -3
- package/commands/soul.js +14 -14
- package/commands/spaceship.js +1 -1
- package/commands/status.js +2 -2
- package/commands/strings.js +13 -13
- package/commands/study.js +4 -4
- package/commands/sync.js +8 -8
- package/commands/task.js +50 -25
- package/commands/teach.js +2 -2
- package/commands/team.js +1 -1
- package/commands/theme.js +2 -2
- package/commands/tree.js +34 -0
- package/commands/truth.js +7 -7
- package/commands/verify.js +12 -12
- package/commands/visualize.js +2 -2
- package/commands/workflow.js +101 -48
- package/commands/worktree.js +1 -1
- package/commands/write.js +9 -9
- package/commands/x-search.js +93 -22
- package/commands/xp.js +6 -3
- package/commands/youtube.js +1019 -123
- package/lib/accept-verify-gate.js +3 -3
- package/lib/activity-stream.js +1 -1
- package/lib/auto-accept-certified.js +28 -12
- package/lib/autoland.js +6 -6
- package/lib/bench/engines.js +59 -8
- package/lib/bench/report.js +1 -1
- package/lib/bench/runner.js +204 -5
- package/lib/bench/tree-render.js +76 -0
- package/lib/card.js +1 -1
- package/lib/chat-log-scan.js +1 -1
- package/lib/claude-boot-block.js +2 -0
- package/lib/deck-compose.js +1 -1
- package/lib/deck-history.js +2 -2
- package/lib/deck-layout.js +1 -1
- package/lib/deck-review.js +5 -5
- package/lib/deck-schema.js +1 -1
- package/lib/default-verifier.js +1 -1
- package/lib/developer-api.js +3 -0
- package/lib/engine-ask.js +45 -7
- package/lib/engine-registry.js +15 -2
- package/lib/file-ops.js +1 -1
- package/lib/fleet.js +71 -35
- package/lib/html-render.js +1 -1
- package/lib/intents.js +172 -0
- package/lib/journal.js +1 -1
- package/lib/known-commands.js +2 -2
- package/lib/land-green.js +364 -0
- package/lib/lesson-contradiction.js +2 -2
- package/lib/lesson-preflight.js +1 -1
- package/lib/manifest.js +5 -5
- package/lib/member-alive.js +9 -5
- package/lib/memory-view.js +3 -3
- package/lib/mission-protected-lane.js +1 -1
- package/lib/mission-root.js +2 -2
- package/lib/orb-context.js +10 -10
- package/lib/pack-capabilities.js +861 -3
- package/lib/permission-grants.js +2 -2
- package/lib/policy-lessons.js +6 -6
- package/lib/pulse.js +8 -8
- package/lib/receipt-evidence.js +1 -1
- package/lib/reel.js +1 -1
- package/lib/review-integrity.js +1 -1
- package/lib/runner-command.js +35 -5
- package/lib/scorecard.js +4 -4
- package/lib/security-scan.js +5 -5
- package/lib/self-drive.js +1 -1
- package/lib/site-publish.js +372 -0
- package/lib/site.js +1 -1
- package/lib/slides-deck.js +2 -2
- package/lib/state-detection.js +2 -2
- package/lib/sync-telemetry.js +2 -2
- package/lib/task-db.js +17 -10
- package/lib/task-explanation.js +1 -1
- package/lib/task-proof.js +2 -2
- package/lib/task-receipt.js +3 -3
- package/lib/theme.js +12 -12
- package/lib/todo-fallback.js +3 -3
- package/lib/todo-sections.js +1 -1
- package/lib/tree-hash.js +79 -0
- package/lib/usage.js +1 -1
- package/lib/wish-delegate.js +1 -1
- package/lib/workspace-safety.js +4 -4
- package/lib/workspace-scaffold.js +3 -3
- package/package.json +1 -1
- package/scripts/det/concierge-bakeoff.sh +26 -0
- package/scripts/det/ytnotes +82 -5
- package/scripts/det/ytquote-repair.js +0 -1
- package/scripts/det/ytrail-race.js +0 -5
- package/scripts/member-operate.mjs +52 -14
- package/scripts/outbound-artifact-gate.js +0 -3
- package/utils/auth.js +47 -9
- package/utils/config.js +0 -1
- package/scripts/det/checklist-score.js +0 -191
package/lib/pack-capabilities.js
CHANGED
|
@@ -5,7 +5,8 @@ const os = require('os');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const dns = require('dns').promises;
|
|
7
7
|
const net = require('net');
|
|
8
|
-
const { randomUUID } = require('crypto');
|
|
8
|
+
const { randomUUID, createHash } = require('crypto');
|
|
9
|
+
const { isDeepStrictEqual } = require('util');
|
|
9
10
|
const { enforceConfigGuard } = require('./config-guard');
|
|
10
11
|
|
|
11
12
|
const CAPABILITY_DEFINITIONS = Object.freeze({
|
|
@@ -35,6 +36,12 @@ const TOOL_ORDER = Object.freeze([
|
|
|
35
36
|
]);
|
|
36
37
|
|
|
37
38
|
const FILE_TOOLS = new Set(['Read', 'Glob', 'Grep', 'Edit', 'Write']);
|
|
39
|
+
// File tools that mutate bytes. Only these are journaled with an action
|
|
40
|
+
// identity and only these are ever denied as recovered-completed paths.
|
|
41
|
+
const FILE_MUTATION_TOOLS = new Set(['Edit', 'Write']);
|
|
42
|
+
// Journal support marker. Runs launched before this marker carry no per-action
|
|
43
|
+
// identity, so their receipts can never back a recovery.
|
|
44
|
+
const PACK_JOURNAL_VERSION = 1;
|
|
38
45
|
const PRE_TOOL_USE_MATCHER = 'Read|Glob|Grep|Edit|Write|WebFetch|Bash';
|
|
39
46
|
const CLAUDE_SESSION_END_REASONS = new Set([
|
|
40
47
|
'clear',
|
|
@@ -271,6 +278,12 @@ function finalizePackRunReceipt(receiptPath, eventsPath) {
|
|
|
271
278
|
));
|
|
272
279
|
const exit = [...events].reverse().find((event) => event.event === 'exit');
|
|
273
280
|
const sessionEnd = [...events].reverse().find((event) => event.event === 'session-end');
|
|
281
|
+
const fileEffects = pairPackFileEffects(events);
|
|
282
|
+
const inheritedProtected = requireInheritedProtection(launch, null);
|
|
283
|
+
const protectedFiles = mergeProtectedFiles(inheritedProtected, fileEffects.confirmed);
|
|
284
|
+
const recoveryMode = launch.recovery && launch.recovery.mode === 'recovered' ? 'recovered' : 'fresh';
|
|
285
|
+
const journalSupported = Boolean(launch.journal && launch.journal.supported === true
|
|
286
|
+
&& launch.journal.version === PACK_JOURNAL_VERSION);
|
|
274
287
|
const summary = {
|
|
275
288
|
schema: launch.schema,
|
|
276
289
|
runId: launch.runId,
|
|
@@ -292,12 +305,32 @@ function finalizePackRunReceipt(receiptPath, eventsPath) {
|
|
|
292
305
|
usedTools,
|
|
293
306
|
deniedUses: events.filter((event) => event.event === 'denied').map(({ at, tool, reason }) => ({ at, tool, reason })),
|
|
294
307
|
failedTools: events.filter((event) => event.event === 'failed').map(({ at, tool }) => ({ at, tool })),
|
|
308
|
+
journal: launch.journal && typeof launch.journal === 'object'
|
|
309
|
+
? launch.journal
|
|
310
|
+
: { version: 0, supported: false },
|
|
311
|
+
fileEffects: {
|
|
312
|
+
confirmed: fileEffects.confirmed,
|
|
313
|
+
unresolved: fileEffects.unresolved,
|
|
314
|
+
failed: fileEffects.failed,
|
|
315
|
+
},
|
|
316
|
+
recovery: {
|
|
317
|
+
mode: recoveryMode,
|
|
318
|
+
...(recoveryMode === 'recovered'
|
|
319
|
+
? { parentRunId: launch.recovery.parentRunId || null, parentReceipt: launch.recovery.parentReceipt || null }
|
|
320
|
+
: {}),
|
|
321
|
+
protectedFiles,
|
|
322
|
+
journalVersion: (launch.journal && launch.journal.version) || 0,
|
|
323
|
+
hasMissingIdentity: fileEffects.unresolved.some((item) => item.reason === 'missing-identity'),
|
|
324
|
+
},
|
|
295
325
|
observability: {
|
|
296
326
|
denialCoverage: 'atris-hooks-only',
|
|
297
327
|
runtimePermissionDenialsCaptured: false,
|
|
298
328
|
toolInputsLogged: false,
|
|
299
329
|
directSkillInvocationsCaptured: false,
|
|
300
330
|
runnerExitCaptured: Boolean(exit),
|
|
331
|
+
fileEffectIdentitiesLogged: journalSupported,
|
|
332
|
+
recoveryLinked: recoveryMode === 'recovered',
|
|
333
|
+
protectedFilesCarried: protectedFiles.length,
|
|
301
334
|
},
|
|
302
335
|
enforcement: launch.enforcement,
|
|
303
336
|
events: path.basename(eventsPath),
|
|
@@ -309,6 +342,796 @@ function finalizePackRunReceipt(receiptPath, eventsPath) {
|
|
|
309
342
|
return summary;
|
|
310
343
|
}
|
|
311
344
|
|
|
345
|
+
// ── file-effect journal and explicit recovery ─────────────────────────────
|
|
346
|
+
// Intent is journaled in PreToolUse after every guard approves but before the
|
|
347
|
+
// tool executes, keyed by tool_use_id plus canonical relative target. No raw
|
|
348
|
+
// content or arguments ever enter the journal. PostToolUse confirms the same
|
|
349
|
+
// identity with the on-disk SHA-256. A missing post stays unresolved, and an
|
|
350
|
+
// interactive permission denial (prompt answered "no") can also leave an
|
|
351
|
+
// intent with no post hook, so recovery primarily serves headless/trusted
|
|
352
|
+
// runs and refuses anything unresolved rather than risk replay.
|
|
353
|
+
function hookToolUseId(input) {
|
|
354
|
+
const id = input && input.tool_use_id;
|
|
355
|
+
return typeof id === 'string' && id.trim() ? id : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function sha256Hex(data) {
|
|
359
|
+
return createHash('sha256').update(data).digest('hex');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function journalRelativeTarget(tool, toolInput, root) {
|
|
363
|
+
const targetValue = fileToolTarget(tool, toolInput || {}, root);
|
|
364
|
+
if (!targetValue) return null;
|
|
365
|
+
const realRoot = fs.realpathSync(root);
|
|
366
|
+
const rel = path.relative(realRoot, path.resolve(realRoot, targetValue));
|
|
367
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
368
|
+
return rel.split(path.sep).join('/');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readFileIdentity(absPath) {
|
|
372
|
+
try {
|
|
373
|
+
const stat = fs.statSync(absPath);
|
|
374
|
+
if (!stat.isFile()) return { present: true, isFile: false };
|
|
375
|
+
return {
|
|
376
|
+
present: true,
|
|
377
|
+
isFile: true,
|
|
378
|
+
sha256: sha256Hex(fs.readFileSync(absPath)),
|
|
379
|
+
dev: stat.dev,
|
|
380
|
+
ino: stat.ino,
|
|
381
|
+
nlink: stat.nlink,
|
|
382
|
+
};
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { present: false };
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function readProtectedFiles() {
|
|
390
|
+
const raw = process.env.ATRIS_PACK_PROTECTED_FILES;
|
|
391
|
+
if (raw === undefined || raw === null || raw === '') return { ok: true, files: [] };
|
|
392
|
+
try {
|
|
393
|
+
const parsed = JSON.parse(raw);
|
|
394
|
+
if (!Array.isArray(parsed)) return { ok: false, files: [] };
|
|
395
|
+
const files = [];
|
|
396
|
+
for (const entry of parsed) {
|
|
397
|
+
if (!entry || typeof entry.target !== 'string' || typeof entry.sha256 !== 'string') {
|
|
398
|
+
return { ok: false, files: [] };
|
|
399
|
+
}
|
|
400
|
+
files.push(entry);
|
|
401
|
+
}
|
|
402
|
+
return { ok: true, files };
|
|
403
|
+
} catch {
|
|
404
|
+
return { ok: false, files: [] };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function readAuthoritativeProtectedFiles(env) {
|
|
409
|
+
const raw = fs.readFileSync(env.eventsPath, 'utf8');
|
|
410
|
+
let launch = null;
|
|
411
|
+
for (const line of raw.split('\n')) {
|
|
412
|
+
if (!line.trim()) continue;
|
|
413
|
+
const parsed = JSON.parse(line);
|
|
414
|
+
if (parsed && parsed.event === 'launch') {
|
|
415
|
+
if (launch) throw new Error('recovery journal has duplicate launch events');
|
|
416
|
+
launch = parsed;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!launch) throw new Error('recovery journal is missing its launch event');
|
|
420
|
+
const recovery = launch.recovery || { mode: 'fresh' };
|
|
421
|
+
if (recovery.mode !== undefined && recovery.mode !== 'fresh' && recovery.mode !== 'recovered') {
|
|
422
|
+
throw new Error('recovery journal has malformed recovered protection');
|
|
423
|
+
}
|
|
424
|
+
if (recovery.mode === 'recovered') {
|
|
425
|
+
if (typeof recovery.parentRunId !== 'string' || !recovery.parentRunId
|
|
426
|
+
|| typeof recovery.parentReceipt !== 'string' || !recovery.parentReceipt
|
|
427
|
+
|| !Array.isArray(recovery.protectedFiles)) {
|
|
428
|
+
throw new Error('recovery journal has malformed recovered protection');
|
|
429
|
+
}
|
|
430
|
+
for (const entry of recovery.protectedFiles) {
|
|
431
|
+
if (!entry || typeof entry.target !== 'string' || typeof entry.sha256 !== 'string'
|
|
432
|
+
|| !/^[0-9a-f]{64}$/.test(entry.sha256)) {
|
|
433
|
+
throw new Error('recovery journal has malformed recovered protection');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { mode: 'recovered', files: recovery.protectedFiles };
|
|
437
|
+
}
|
|
438
|
+
if (!Array.isArray(recovery.protectedFiles || [])) throw new Error('recovery journal has malformed protection');
|
|
439
|
+
return { mode: 'fresh', files: Array.isArray(recovery.protectedFiles) ? recovery.protectedFiles : [] };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function protectedFileMatch(rel, absIdentity, protectedFiles) {
|
|
443
|
+
for (const entry of protectedFiles) {
|
|
444
|
+
if (entry.target === rel) return entry;
|
|
445
|
+
// Device/inode pairing catches case aliases on macOS, where a differently
|
|
446
|
+
// cased path resolves to the same bytes through no new directory entry.
|
|
447
|
+
if (absIdentity && absIdentity.present && absIdentity.isFile !== false
|
|
448
|
+
&& Number.isFinite(entry.dev) && Number.isFinite(entry.ino)
|
|
449
|
+
&& absIdentity.dev === entry.dev && absIdentity.ino === entry.ino) {
|
|
450
|
+
return entry;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Runs after the file guards approve. Denies recovered-completed paths by
|
|
457
|
+
// path AND device/inode; anything else passes through to journaling.
|
|
458
|
+
// Fails closed: malformed protection, unreadable journal, or identity errors
|
|
459
|
+
// deny rather than becoming an empty set.
|
|
460
|
+
function isAuthoritativeJournalPath(env, lexical) {
|
|
461
|
+
for (const candidate of [env.receiptPath, env.eventsPath]) {
|
|
462
|
+
if (!candidate) continue;
|
|
463
|
+
try {
|
|
464
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
465
|
+
let realLexical = null;
|
|
466
|
+
try {
|
|
467
|
+
realLexical = realBoundaryPath(lexical);
|
|
468
|
+
} catch {
|
|
469
|
+
realLexical = null;
|
|
470
|
+
}
|
|
471
|
+
if (realLexical && realLexical === realCandidate) return true;
|
|
472
|
+
try {
|
|
473
|
+
const a = fs.statSync(lexical);
|
|
474
|
+
const b = fs.statSync(candidate);
|
|
475
|
+
if (a.dev === b.dev && a.ino === b.ino) return true;
|
|
476
|
+
} catch {
|
|
477
|
+
// Fall through to lexical comparison below; stat failures stay denied
|
|
478
|
+
// only if the paths already matched, never as an allow.
|
|
479
|
+
}
|
|
480
|
+
if (path.resolve(candidate) === lexical) return true;
|
|
481
|
+
} catch {
|
|
482
|
+
if (path.resolve(candidate) === lexical) return true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function checkProtectedMutation(env, input) {
|
|
489
|
+
const tool = input && input.tool_name;
|
|
490
|
+
if (!FILE_MUTATION_TOOLS.has(tool)) return null;
|
|
491
|
+
const toolInput = input.tool_input || {};
|
|
492
|
+
const targetValue = fileToolTarget(tool, toolInput, env.root);
|
|
493
|
+
if (!targetValue) return null;
|
|
494
|
+
const realRoot = fs.realpathSync(env.root);
|
|
495
|
+
const lexical = path.resolve(realRoot, targetValue);
|
|
496
|
+
const rel = path.relative(realRoot, lexical).split(path.sep).join('/');
|
|
497
|
+
if (isAuthoritativeJournalPath(env, lexical)) {
|
|
498
|
+
return { denied: true, reason: `${tool} to the pack run journal is denied: the journal is outside model reach` };
|
|
499
|
+
}
|
|
500
|
+
let identity = null;
|
|
501
|
+
try {
|
|
502
|
+
const stat = fs.statSync(lexical);
|
|
503
|
+
identity = stat.isFile()
|
|
504
|
+
? { present: true, dev: stat.dev, ino: stat.ino }
|
|
505
|
+
: { present: true };
|
|
506
|
+
} catch (error) {
|
|
507
|
+
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
|
|
508
|
+
identity = { present: false };
|
|
509
|
+
} else {
|
|
510
|
+
return { denied: true, reason: `${tool} identity check failed; refusing the mutation rather than risk replay` };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
let authoritative = null;
|
|
514
|
+
try {
|
|
515
|
+
authoritative = readAuthoritativeProtectedFiles(env);
|
|
516
|
+
} catch {
|
|
517
|
+
return { denied: true, reason: `${tool} protection state is unreadable; refusing the mutation rather than risk replay` };
|
|
518
|
+
}
|
|
519
|
+
const envBlob = readProtectedFiles();
|
|
520
|
+
if (!envBlob.ok) {
|
|
521
|
+
return { denied: true, reason: `${tool} protection state is malformed; refusing the mutation rather than risk replay` };
|
|
522
|
+
}
|
|
523
|
+
const combined = [...authoritative.files];
|
|
524
|
+
for (const entry of envBlob.files) {
|
|
525
|
+
if (!combined.some((item) => item.target === entry.target && item.sha256 === entry.sha256)) {
|
|
526
|
+
combined.push(entry);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (authoritative.mode === 'recovered') {
|
|
530
|
+
const envTargets = new Set(envBlob.files.map((entry) => entry.target));
|
|
531
|
+
for (const entry of authoritative.files) {
|
|
532
|
+
if (!envTargets.has(entry.target)) {
|
|
533
|
+
return { denied: true, reason: `${tool} protection state is incomplete; refusing the mutation rather than risk replay` };
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const hit = protectedFileMatch(rel, identity, combined);
|
|
538
|
+
if (hit) {
|
|
539
|
+
return { denied: true, reason: `${tool} to ${hit.target} is denied: the file completed in a prior run and is protected during recovery` };
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function journalFileIntent(env, input) {
|
|
545
|
+
const tool = input && input.tool_name;
|
|
546
|
+
if (!FILE_MUTATION_TOOLS.has(tool)) return;
|
|
547
|
+
appendReceiptEvent(env.eventsPath, {
|
|
548
|
+
event: 'intent',
|
|
549
|
+
at: new Date().toISOString(),
|
|
550
|
+
tool,
|
|
551
|
+
tool_use_id: hookToolUseId(input),
|
|
552
|
+
target: journalRelativeTarget(tool, input.tool_input || {}, env.root),
|
|
553
|
+
});
|
|
554
|
+
// A journal write failure throws, so pre-hook setup exits 2 and the tool
|
|
555
|
+
// never runs unrecorded.
|
|
556
|
+
finalizePackRunReceipt(env.receiptPath, env.eventsPath);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function sanitizeLaunchRecovery(recovery) {
|
|
560
|
+
if (!recovery || typeof recovery !== 'object') return { mode: 'fresh' };
|
|
561
|
+
const protectedFiles = Array.isArray(recovery.protectedFiles)
|
|
562
|
+
? recovery.protectedFiles
|
|
563
|
+
.filter((entry) => entry && typeof entry.target === 'string' && typeof entry.sha256 === 'string')
|
|
564
|
+
.map((entry) => ({ target: entry.target, sha256: entry.sha256, dev: entry.dev, ino: entry.ino }))
|
|
565
|
+
: [];
|
|
566
|
+
return {
|
|
567
|
+
mode: 'recovered',
|
|
568
|
+
parentRunId: typeof recovery.parentRunId === 'string' ? recovery.parentRunId : null,
|
|
569
|
+
parentReceipt: typeof recovery.parentReceipt === 'string' ? recovery.parentReceipt : null,
|
|
570
|
+
protectedFiles,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function mergeProtectedFiles(inherited, confirmed) {
|
|
575
|
+
const merged = [...inherited];
|
|
576
|
+
for (const entry of confirmed) {
|
|
577
|
+
const record = { target: entry.target, sha256: entry.sha256, dev: entry.dev, ino: entry.ino };
|
|
578
|
+
const index = merged.findIndex((item) => item.target === record.target);
|
|
579
|
+
if (index === -1) merged.push(record);
|
|
580
|
+
else merged[index] = record;
|
|
581
|
+
}
|
|
582
|
+
return merged;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Pairs intent events with their post events by tool_use_id. Anything that
|
|
586
|
+
// does not pair cleanly is unresolved, including confirmations that arrive
|
|
587
|
+
// without an intent and intents whose confirmation never arrived. Strict:
|
|
588
|
+
// each mutation has exactly one identity, intent precedes post with same
|
|
589
|
+
// tool AND target, duplicates/unknown shapes/malformed hashes refuse.
|
|
590
|
+
function pairPackFileEffects(events) {
|
|
591
|
+
const confirmed = [];
|
|
592
|
+
const unresolved = [];
|
|
593
|
+
const failed = [];
|
|
594
|
+
const KNOWN_TOOLS = new Set([...TOOL_ORDER, ...FILE_MUTATION_TOOLS]);
|
|
595
|
+
const intentIndices = new Map();
|
|
596
|
+
const postIndices = new Map();
|
|
597
|
+
events.forEach((event, index) => {
|
|
598
|
+
if (!event || typeof event.event !== 'string') {
|
|
599
|
+
unresolved.push({ tool: null, target: null, tool_use_id: null, reason: 'malformed-event' });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (event.event === 'intent') {
|
|
603
|
+
if (!FILE_MUTATION_TOOLS.has(event.tool)) {
|
|
604
|
+
unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: event.tool_use_id || null, reason: 'unknown-tool' });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const id = typeof event.tool_use_id === 'string' && event.tool_use_id ? event.tool_use_id : null;
|
|
608
|
+
if (!id || typeof event.target !== 'string' || !event.target) {
|
|
609
|
+
unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: id, reason: 'missing-identity' });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (intentIndices.has(id)) {
|
|
613
|
+
unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: id, reason: 'duplicate-intent' });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
intentIndices.set(id, index);
|
|
617
|
+
} else if (event.event === 'used' || event.event === 'failed') {
|
|
618
|
+
if (!event.tool || !KNOWN_TOOLS.has(event.tool)) {
|
|
619
|
+
unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: event.tool_use_id || null, reason: 'unknown-tool' });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (!FILE_MUTATION_TOOLS.has(event.tool)) return;
|
|
623
|
+
const id = typeof event.tool_use_id === 'string' && event.tool_use_id ? event.tool_use_id : null;
|
|
624
|
+
if (!id) {
|
|
625
|
+
unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: null, reason: 'missing-identity' });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (postIndices.has(id)) {
|
|
629
|
+
unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: id, reason: 'duplicate-post' });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
postIndices.set(id, index);
|
|
633
|
+
} else if (!['launch', 'exit', 'denied', 'session-end'].includes(event.event)) {
|
|
634
|
+
unresolved.push({ tool: event.tool || null, target: event.target || null, tool_use_id: event.tool_use_id || null, reason: 'unknown-event' });
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
const intents = [];
|
|
638
|
+
const postsById = new Map();
|
|
639
|
+
events.forEach((event) => {
|
|
640
|
+
if (event.event === 'intent' && FILE_MUTATION_TOOLS.has(event.tool)
|
|
641
|
+
&& typeof event.tool_use_id === 'string' && event.tool_use_id
|
|
642
|
+
&& typeof event.target === 'string' && event.target
|
|
643
|
+
&& !intents.some((item) => item.tool_use_id === event.tool_use_id)) {
|
|
644
|
+
intents.push(event);
|
|
645
|
+
}
|
|
646
|
+
if ((event.event === 'used' || event.event === 'failed') && FILE_MUTATION_TOOLS.has(event.tool)
|
|
647
|
+
&& typeof event.tool_use_id === 'string' && event.tool_use_id
|
|
648
|
+
&& !Array.from(postsById.values()).flat().includes(event)) {
|
|
649
|
+
if (!postsById.has(event.tool_use_id)) postsById.set(event.tool_use_id, []);
|
|
650
|
+
postsById.get(event.tool_use_id).push(event);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
const consumed = new Set();
|
|
654
|
+
for (const intent of intents) {
|
|
655
|
+
const id = intent.tool_use_id;
|
|
656
|
+
const post = ((postsById.get(id) || []).filter((candidate) => !consumed.has(candidate)))[0] || null;
|
|
657
|
+
if (!post) {
|
|
658
|
+
if (!unresolved.some((item) => item.tool_use_id === id)) {
|
|
659
|
+
unresolved.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id, reason: 'missing-post' });
|
|
660
|
+
}
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
consumed.add(post);
|
|
664
|
+
const intentIndex = events.indexOf(intent);
|
|
665
|
+
const postIndex = events.indexOf(post);
|
|
666
|
+
if (postIndex < intentIndex) {
|
|
667
|
+
unresolved.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id, reason: 'out-of-order' });
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (post.tool !== intent.tool || post.target !== intent.target) {
|
|
671
|
+
unresolved.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id, reason: 'confirmation-mismatch' });
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (post.event === 'failed') {
|
|
675
|
+
failed.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id });
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (typeof post.fileSha256 === 'string' && /^[0-9a-f]{64}$/.test(post.fileSha256)) {
|
|
679
|
+
confirmed.push({
|
|
680
|
+
tool: intent.tool || null, target: intent.target || null,
|
|
681
|
+
sha256: post.fileSha256, dev: post.dev, ino: post.ino, tool_use_id: id,
|
|
682
|
+
});
|
|
683
|
+
} else {
|
|
684
|
+
unresolved.push({ tool: intent.tool || null, target: intent.target || null, tool_use_id: id, reason: 'confirmation-mismatch' });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const [id, list] of postsById.entries()) {
|
|
688
|
+
for (const post of list) {
|
|
689
|
+
if (consumed.has(post)) continue;
|
|
690
|
+
unresolved.push({ tool: post.tool || null, target: post.target || null, tool_use_id: id, reason: 'missing-intent' });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return { confirmed, unresolved, failed };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function readStrictPackEvents(eventsPath) {
|
|
697
|
+
const raw = fs.readFileSync(eventsPath, 'utf8');
|
|
698
|
+
const events = [];
|
|
699
|
+
for (const line of raw.split('\n')) {
|
|
700
|
+
if (!line.trim()) continue;
|
|
701
|
+
let parsed = null;
|
|
702
|
+
try {
|
|
703
|
+
parsed = JSON.parse(line);
|
|
704
|
+
} catch {
|
|
705
|
+
throw new Error('recovery journal is corrupt (unparseable event); refusing recovery rather than risk replay');
|
|
706
|
+
}
|
|
707
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.event !== 'string') {
|
|
708
|
+
throw new Error('recovery journal is corrupt (malformed event); refusing recovery rather than risk replay');
|
|
709
|
+
}
|
|
710
|
+
events.push(parsed);
|
|
711
|
+
}
|
|
712
|
+
return events;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function assertJournalTarget(target, root) {
|
|
716
|
+
if (typeof target !== 'string' || !target || target.startsWith('/') || target.split('/').includes('..')) {
|
|
717
|
+
throw new Error('recovery journal is corrupt (unsafe file target); refusing recovery rather than risk replay');
|
|
718
|
+
}
|
|
719
|
+
if (!insideRoot(path.resolve(root, target), root)) {
|
|
720
|
+
throw new Error('recovery journal records a path outside the pack root; refusing recovery');
|
|
721
|
+
}
|
|
722
|
+
return path.resolve(root, target);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function realPathOutsideCheck(candidatePath, rootReal) {
|
|
726
|
+
let real = null;
|
|
727
|
+
try {
|
|
728
|
+
real = fs.realpathSync(candidatePath);
|
|
729
|
+
} catch {
|
|
730
|
+
try {
|
|
731
|
+
real = realBoundaryPath(path.resolve(candidatePath));
|
|
732
|
+
} catch {
|
|
733
|
+
throw new Error('recovery path could not be resolved safely outside the pack root; refusing recovery');
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (insideRoot(real, rootReal) || real === rootReal) {
|
|
737
|
+
throw new Error('recovery receipt, journal, claim, or child receipt directory must live outside the pack root; refusing recovery');
|
|
738
|
+
}
|
|
739
|
+
return real;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function assertRecoveryPlacementOutsideRoot({ rootReal, parentReceiptPath, eventsPath, claimPath, childReceiptDir }) {
|
|
743
|
+
for (const candidate of [parentReceiptPath, eventsPath, claimPath].filter(Boolean)) {
|
|
744
|
+
realPathOutsideCheck(candidate, rootReal);
|
|
745
|
+
try {
|
|
746
|
+
const stat = fs.statSync(candidate);
|
|
747
|
+
if (stat.isFile() && stat.nlink > 1) {
|
|
748
|
+
throw new Error('recovery receipt, journal, or claim has multiple hard links; manual review needed before recovery');
|
|
749
|
+
}
|
|
750
|
+
} catch (error) {
|
|
751
|
+
if (error && error.message && error.message.includes('multiple hard links')) throw error;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (childReceiptDir) {
|
|
755
|
+
let realChild = null;
|
|
756
|
+
try {
|
|
757
|
+
realChild = realBoundaryPath(path.resolve(childReceiptDir));
|
|
758
|
+
} catch {
|
|
759
|
+
throw new Error('recovery child receipt directory could not be resolved safely outside the pack root; refusing recovery');
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
const existing = fs.realpathSync(childReceiptDir);
|
|
763
|
+
realChild = existing;
|
|
764
|
+
} catch {
|
|
765
|
+
// Use boundary-resolved path above for not-yet-created directories.
|
|
766
|
+
}
|
|
767
|
+
if (insideRoot(realChild, rootReal) || realChild === rootReal) {
|
|
768
|
+
throw new Error('recovery child receipt directory must live outside the pack root; refusing recovery');
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Strict launch recovery shape. A recovered launch must carry a valid parent
|
|
774
|
+
// identity and an array of well-formed protected identities; absent or
|
|
775
|
+
// malformed recovered state refuses instead of silently becoming an empty
|
|
776
|
+
// protection set. The summary's recorded recovery identity must agree with
|
|
777
|
+
// the journal rather than letting a stale summary pass as fresh.
|
|
778
|
+
function requireInheritedProtection(launch, summary) {
|
|
779
|
+
const recovery = launch.recovery;
|
|
780
|
+
if (recovery !== undefined && recovery !== null && typeof recovery !== 'object') {
|
|
781
|
+
throw new Error('recovery journal is corrupt (malformed recovery metadata); refusing recovery rather than risk replay');
|
|
782
|
+
}
|
|
783
|
+
const mode = recovery && typeof recovery === 'object' ? recovery.mode : undefined;
|
|
784
|
+
if (mode !== undefined && mode !== 'fresh' && mode !== 'recovered') {
|
|
785
|
+
throw new Error('recovery journal is corrupt (unknown recovery mode); refusing recovery rather than risk replay');
|
|
786
|
+
}
|
|
787
|
+
let launchFiles = [];
|
|
788
|
+
if (mode === 'recovered') {
|
|
789
|
+
if (typeof recovery.parentRunId !== 'string' || !recovery.parentRunId
|
|
790
|
+
|| typeof recovery.parentReceipt !== 'string' || !recovery.parentReceipt) {
|
|
791
|
+
throw new Error('recovery journal is corrupt (malformed recovered parent identity); refusing recovery rather than risk replay');
|
|
792
|
+
}
|
|
793
|
+
if (!Array.isArray(recovery.protectedFiles)) {
|
|
794
|
+
throw new Error('recovery journal is corrupt (malformed recovered protection); refusing recovery rather than risk replay');
|
|
795
|
+
}
|
|
796
|
+
for (const entry of recovery.protectedFiles) {
|
|
797
|
+
if (!entry || typeof entry.target !== 'string' || typeof entry.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(entry.sha256)) {
|
|
798
|
+
throw new Error('recovery journal is corrupt (malformed protected entry); refusing recovery rather than risk replay');
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
launchFiles = recovery.protectedFiles;
|
|
802
|
+
} else if (recovery && recovery.protectedFiles !== undefined
|
|
803
|
+
&& (!Array.isArray(recovery.protectedFiles) || recovery.protectedFiles.length !== 0)) {
|
|
804
|
+
throw new Error('recovery journal is corrupt (malformed fresh protection); refusing recovery rather than risk replay');
|
|
805
|
+
}
|
|
806
|
+
if (!summary) return launchFiles;
|
|
807
|
+
const summaryRecovery = summary.recovery;
|
|
808
|
+
if (!summaryRecovery || typeof summaryRecovery !== 'object') {
|
|
809
|
+
throw new Error('recovery summary is missing stable recovery metadata; refusing recovery rather than risk replay');
|
|
810
|
+
}
|
|
811
|
+
if (mode === 'recovered') {
|
|
812
|
+
if (summaryRecovery.mode !== undefined && summaryRecovery.mode !== 'recovered') {
|
|
813
|
+
throw new Error('recovery summary conflicts with its journal recovery identity; refusing recovery rather than risk replay');
|
|
814
|
+
}
|
|
815
|
+
if ((summaryRecovery.parentRunId || null) !== recovery.parentRunId
|
|
816
|
+
|| (summaryRecovery.parentReceipt || null) !== recovery.parentReceipt) {
|
|
817
|
+
throw new Error('recovery summary conflicts with its journal recovery identity; refusing recovery rather than risk replay');
|
|
818
|
+
}
|
|
819
|
+
} else if (summaryRecovery.mode === 'recovered') {
|
|
820
|
+
throw new Error('recovery summary conflicts with its journal recovery identity; refusing recovery rather than risk replay');
|
|
821
|
+
}
|
|
822
|
+
return launchFiles;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Strict tool ceiling. The journal's granted tools must exactly equal the
|
|
826
|
+
// canonical tools implied by its validated capabilities: no extras, unknown
|
|
827
|
+
// names, duplicates, or reorderings. Stable summary evidence must agree.
|
|
828
|
+
function requireExactToolCeiling(launch, summary) {
|
|
829
|
+
const granted = launch.grantedCapabilities;
|
|
830
|
+
if (!Array.isArray(granted)) {
|
|
831
|
+
throw new Error('recovery journal is corrupt (missing granted capabilities)');
|
|
832
|
+
}
|
|
833
|
+
const seenCaps = new Set();
|
|
834
|
+
for (const cap of granted) {
|
|
835
|
+
if (typeof cap !== 'string' || !CAPABILITY_DEFINITIONS[cap]) {
|
|
836
|
+
throw new Error('recovery journal is corrupt (unknown granted capability); refusing recovery rather than risk replay');
|
|
837
|
+
}
|
|
838
|
+
if (seenCaps.has(cap)) {
|
|
839
|
+
throw new Error('recovery journal is corrupt (duplicate granted capability); refusing recovery rather than risk replay');
|
|
840
|
+
}
|
|
841
|
+
seenCaps.add(cap);
|
|
842
|
+
}
|
|
843
|
+
if (!Array.isArray(launch.requestedCapabilities)) {
|
|
844
|
+
throw new Error('recovery journal is corrupt (missing tool ceiling); refusing recovery rather than risk replay');
|
|
845
|
+
}
|
|
846
|
+
const seenRequested = new Set();
|
|
847
|
+
for (const cap of launch.requestedCapabilities) {
|
|
848
|
+
if (typeof cap !== 'string' || !CAPABILITY_DEFINITIONS[cap]) {
|
|
849
|
+
throw new Error('recovery journal is corrupt (unknown requested capability); refusing recovery rather than risk replay');
|
|
850
|
+
}
|
|
851
|
+
if (seenRequested.has(cap)) {
|
|
852
|
+
throw new Error('recovery journal is corrupt (duplicate requested capability); refusing recovery rather than risk replay');
|
|
853
|
+
}
|
|
854
|
+
seenRequested.add(cap);
|
|
855
|
+
if (!seenCaps.has(cap)) {
|
|
856
|
+
throw new Error('recovery journal is corrupt (requested capability outside granted set); refusing recovery rather than risk replay');
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const expectedTools = toolsForCapabilities(granted);
|
|
860
|
+
if (!Array.isArray(launch.grantedTools) || launch.grantedTools.length !== expectedTools.length
|
|
861
|
+
|| launch.grantedTools.some((tool, index) => tool !== expectedTools[index])) {
|
|
862
|
+
throw new Error('recovery journal is corrupt (tool ceiling does not match granted capabilities); refusing recovery rather than risk replay');
|
|
863
|
+
}
|
|
864
|
+
if (!summary || typeof summary !== 'object') {
|
|
865
|
+
throw new Error('recovery receipt is corrupt (unrecognized pack run receipt)');
|
|
866
|
+
}
|
|
867
|
+
if (!Array.isArray(summary.grantedCapabilities)
|
|
868
|
+
|| [...summary.grantedCapabilities].sort().join(',') !== [...granted].sort().join(',')) {
|
|
869
|
+
throw new Error('recovery summary conflicts with its journal capabilities; refusing recovery rather than risk replay');
|
|
870
|
+
}
|
|
871
|
+
if (!Array.isArray(summary.grantedTools)
|
|
872
|
+
|| summary.grantedTools.length !== expectedTools.length
|
|
873
|
+
|| summary.grantedTools.some((tool, index) => tool !== expectedTools[index])) {
|
|
874
|
+
throw new Error('recovery summary conflicts with its journal tool ceiling; refusing recovery rather than risk replay');
|
|
875
|
+
}
|
|
876
|
+
if (!Array.isArray(summary.requestedCapabilities)
|
|
877
|
+
|| summary.requestedCapabilities.length !== launch.requestedCapabilities.length
|
|
878
|
+
|| summary.requestedCapabilities.some((cap, index) => cap !== launch.requestedCapabilities[index])) {
|
|
879
|
+
throw new Error('recovery summary conflicts with its journal capability request; refusing recovery rather than risk replay');
|
|
880
|
+
}
|
|
881
|
+
return granted;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Read-only assessment of a parent receipt. Throws a plain error for every
|
|
885
|
+
// refusal; returns the inherited protection set when recovery may proceed.
|
|
886
|
+
// The companion event log is authoritative: exit, caps, pack identity,
|
|
887
|
+
// input, inherited protection, and journal version all derive from launch
|
|
888
|
+
// and exit events. The summary snapshot must agree; a stale summary never
|
|
889
|
+
// grants authority.
|
|
890
|
+
function assessPackRecoveryJournal(options = {}) {
|
|
891
|
+
const packDir = options.packDir;
|
|
892
|
+
const manifest = options.manifest || {};
|
|
893
|
+
const policy = options.policy || {};
|
|
894
|
+
const parentReceiptPath = path.resolve(String(options.parentReceiptPath || ''));
|
|
895
|
+
const operatorInput = options.operatorInput || null;
|
|
896
|
+
const childReceiptDir = options.childReceiptDir ? path.resolve(String(options.childReceiptDir)) : null;
|
|
897
|
+
|
|
898
|
+
let summary = null;
|
|
899
|
+
try {
|
|
900
|
+
summary = JSON.parse(fs.readFileSync(parentReceiptPath, 'utf8'));
|
|
901
|
+
} catch (error) {
|
|
902
|
+
if (error && error.code === 'ENOENT') throw new Error(`recovery receipt not found: ${parentReceiptPath}`);
|
|
903
|
+
throw new Error(`recovery receipt is corrupt or unreadable: ${error.message}`);
|
|
904
|
+
}
|
|
905
|
+
if (!summary || typeof summary !== 'object' || summary.schema !== 'atris.pack-run.v1' || typeof summary.runId !== 'string') {
|
|
906
|
+
throw new Error('recovery receipt is corrupt (unrecognized pack run receipt)');
|
|
907
|
+
}
|
|
908
|
+
if (typeof summary.events !== 'string' || !summary.events || summary.events.includes('/') || summary.events.includes('\\')) {
|
|
909
|
+
throw new Error('recovery journal binding is corrupt (events name); refusing recovery rather than risk replay');
|
|
910
|
+
}
|
|
911
|
+
const root = fs.realpathSync(packDir);
|
|
912
|
+
const eventsPath = path.join(path.dirname(parentReceiptPath), path.basename(summary.events));
|
|
913
|
+
const claimPath = packRecoveryClaimPath(parentReceiptPath);
|
|
914
|
+
assertRecoveryPlacementOutsideRoot({ rootReal: root, parentReceiptPath, eventsPath, claimPath, childReceiptDir });
|
|
915
|
+
let events = null;
|
|
916
|
+
try {
|
|
917
|
+
events = readStrictPackEvents(eventsPath);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
if (error && error.code === 'ENOENT') {
|
|
920
|
+
throw new Error('recovery journal is missing alongside the receipt; refusing recovery rather than risk replay');
|
|
921
|
+
}
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
if (!events.length) throw new Error('recovery journal is corrupt (empty history); refusing recovery rather than risk replay');
|
|
925
|
+
const launches = events.filter((event) => event.event === 'launch');
|
|
926
|
+
if (launches.length !== 1 || events[0].event !== 'launch') {
|
|
927
|
+
throw new Error('recovery journal is corrupt (launch must be first and only); refusing recovery rather than risk replay');
|
|
928
|
+
}
|
|
929
|
+
const launch = launches[0];
|
|
930
|
+
if (!launch.journal || launch.journal.supported !== true || launch.journal.version !== PACK_JOURNAL_VERSION) {
|
|
931
|
+
throw new Error('recovery needs a journaled run; this receipt predates file-effect journaling, so inspect the prior run with manual review');
|
|
932
|
+
}
|
|
933
|
+
if (summary.journal && (summary.journal.supported !== launch.journal.supported || summary.journal.version !== launch.journal.version)) {
|
|
934
|
+
throw new Error('recovery journal conflicts with its receipt summary; refusing recovery rather than risk replay');
|
|
935
|
+
}
|
|
936
|
+
if (launch.runId !== summary.runId) {
|
|
937
|
+
throw new Error('recovery journal is corrupt (launch mismatch); refusing recovery rather than risk replay');
|
|
938
|
+
}
|
|
939
|
+
const exits = events.filter((event) => event.event === 'exit');
|
|
940
|
+
if (exits.length === 0) {
|
|
941
|
+
throw new Error('parent run has no recorded runner exit; recovery needs a nonzero runner exit, so inspect the prior run with manual review');
|
|
942
|
+
}
|
|
943
|
+
if (exits.length !== 1 || events[events.length - 1].event !== 'exit') {
|
|
944
|
+
throw new Error('recovery journal is corrupt (terminal exit must be last and only); refusing recovery rather than risk replay');
|
|
945
|
+
}
|
|
946
|
+
const journalExit = exits[0];
|
|
947
|
+
if (typeof journalExit.status !== 'number' && !journalExit.signal) {
|
|
948
|
+
throw new Error('parent run has no recorded runner exit status; refusing recovery rather than risk replay');
|
|
949
|
+
}
|
|
950
|
+
if (journalExit.status === 0 && !journalExit.signal) {
|
|
951
|
+
throw new Error('parent run succeeded (exit 0); nothing to recover');
|
|
952
|
+
}
|
|
953
|
+
if (summary.status !== 'finished' || summary.exitStatus !== journalExit.status
|
|
954
|
+
|| (summary.signal || null) !== (journalExit.signal || null)) {
|
|
955
|
+
throw new Error('recovery summary conflicts with its journal exit; refusing recovery rather than risk replay');
|
|
956
|
+
}
|
|
957
|
+
const expectedEvents = `${path.basename(parentReceiptPath, '.json')}.events.jsonl`;
|
|
958
|
+
if (path.basename(summary.events) !== expectedEvents) {
|
|
959
|
+
throw new Error('recovery journal binding does not match this receipt; refusing recovery rather than risk replay');
|
|
960
|
+
}
|
|
961
|
+
const journalGranted = requireExactToolCeiling(launch, summary);
|
|
962
|
+
for (const event of events) {
|
|
963
|
+
if (!['intent', 'used', 'failed'].includes(event.event)) continue;
|
|
964
|
+
if (!launch.grantedTools.includes(event.tool)
|
|
965
|
+
|| (event.capability !== undefined && event.capability !== toolCapability(event.tool, journalGranted))) {
|
|
966
|
+
throw new Error('recovery journal has a tool ceiling mismatch (corrupt history); refusing recovery rather than risk replay');
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (journalGranted.includes('host.shell')) {
|
|
970
|
+
throw new Error('parent run granted host.shell; shell effects cannot be reasoned about, so inspect the prior run with manual review');
|
|
971
|
+
}
|
|
972
|
+
if (!policy || policy.status !== 'enforced') {
|
|
973
|
+
throw new Error('pack run --recover needs a declared capability ceiling; legacy packs cannot be recovered');
|
|
974
|
+
}
|
|
975
|
+
if (policy.grantedCapabilities.includes('host.shell')) {
|
|
976
|
+
throw new Error('pack run --recover cannot grant host.shell; shell effects cannot be reasoned about');
|
|
977
|
+
}
|
|
978
|
+
if ([...journalGranted].sort().join(',') !== [...policy.grantedCapabilities].sort().join(',')) {
|
|
979
|
+
throw new Error(`capabilities changed since the parent run (was: ${journalGranted.join(', ') || 'none'}); recovery needs the same granted set`);
|
|
980
|
+
}
|
|
981
|
+
let launchRoot = null;
|
|
982
|
+
try {
|
|
983
|
+
launchRoot = launch.pack && launch.pack.root ? fs.realpathSync(launch.pack.root) : null;
|
|
984
|
+
} catch {
|
|
985
|
+
launchRoot = null;
|
|
986
|
+
}
|
|
987
|
+
if (!launch.pack || typeof launch.pack.slug !== 'string' || typeof launch.pack.version !== 'string' || launchRoot !== root) {
|
|
988
|
+
throw new Error('recovery journal belongs to a different pack, folder, or version; refusing to mix histories');
|
|
989
|
+
}
|
|
990
|
+
if (summary.pack && (summary.pack.slug !== launch.pack.slug || summary.pack.version !== launch.pack.version)) {
|
|
991
|
+
throw new Error('recovery summary conflicts with its journal pack identity; refusing recovery rather than risk replay');
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
const summaryRoot = summary.pack && summary.pack.root ? fs.realpathSync(summary.pack.root) : null;
|
|
995
|
+
if (summaryRoot && summaryRoot !== launchRoot) {
|
|
996
|
+
throw new Error('recovery summary conflicts with its journal pack identity; refusing recovery rather than risk replay');
|
|
997
|
+
}
|
|
998
|
+
} catch {
|
|
999
|
+
throw new Error('recovery summary conflicts with its journal pack identity; refusing recovery rather than risk replay');
|
|
1000
|
+
}
|
|
1001
|
+
if (!launch.pack || launch.pack.slug !== manifest.slug || launch.pack.version !== manifest.version) {
|
|
1002
|
+
throw new Error('recovery receipt belongs to a different pack, folder, or version; refusing to mix histories');
|
|
1003
|
+
}
|
|
1004
|
+
const journalInput = launch.operatorInput ? { bytes: launch.operatorInput.bytes, sha256: launch.operatorInput.sha256 } : null;
|
|
1005
|
+
const summaryInput = summary.operatorInput ? { bytes: summary.operatorInput.bytes, sha256: summary.operatorInput.sha256 } : null;
|
|
1006
|
+
if (JSON.stringify(summaryInput) !== JSON.stringify(journalInput)) {
|
|
1007
|
+
throw new Error('recovery summary conflicts with its journal input; refusing recovery rather than risk replay');
|
|
1008
|
+
}
|
|
1009
|
+
const currentInput = operatorInput ? { bytes: operatorInput.bytes, sha256: operatorInput.sha256 } : null;
|
|
1010
|
+
if (JSON.stringify(journalInput) !== JSON.stringify(currentInput)) {
|
|
1011
|
+
throw new Error('operator input differs from the parent run; recovery needs the same input');
|
|
1012
|
+
}
|
|
1013
|
+
const inherited = requireInheritedProtection(launch, summary);
|
|
1014
|
+
for (const entry of inherited) {
|
|
1015
|
+
assertJournalTarget(entry.target, root);
|
|
1016
|
+
}
|
|
1017
|
+
const effects = pairPackFileEffects(events);
|
|
1018
|
+
for (const item of [...effects.confirmed, ...effects.unresolved, ...effects.failed]) {
|
|
1019
|
+
if (item.target !== null && item.target !== undefined) assertJournalTarget(item.target, root);
|
|
1020
|
+
}
|
|
1021
|
+
// No missing identity may pass recovery, and any intent without its
|
|
1022
|
+
// confirmation (the crash window, or an interactive denial) refuses.
|
|
1023
|
+
// Inspect the prior run; a fresh rerun would risk replaying unknown effects.
|
|
1024
|
+
if (effects.unresolved.length) {
|
|
1025
|
+
const first = effects.unresolved[0];
|
|
1026
|
+
throw new Error(`journal has ${effects.unresolved.length} unresolved file effect(s) (first: ${first.tool || '?'} ${first.target || '?'}: ${first.reason}); inspect the prior run with manual review`);
|
|
1027
|
+
}
|
|
1028
|
+
const lastConfirmed = new Map();
|
|
1029
|
+
for (const entry of effects.confirmed) lastConfirmed.set(entry.target, entry);
|
|
1030
|
+
const inheritedByTarget = new Map();
|
|
1031
|
+
for (const entry of inherited) inheritedByTarget.set(entry.target, entry);
|
|
1032
|
+
const protectedFiles = mergeProtectedFiles(inherited, [...lastConfirmed.values()]);
|
|
1033
|
+
// Recompute both views from the journal, never use one summary field to
|
|
1034
|
+
// justify another. Lost completed or pending events must not become a
|
|
1035
|
+
// shorter, apparently safe history beside an unchanged receipt.
|
|
1036
|
+
if (!isDeepStrictEqual(summary.fileEffects, effects)) {
|
|
1037
|
+
throw new Error('recovery summary file effects conflict with its journal (corrupt history); refusing recovery rather than risk replay');
|
|
1038
|
+
}
|
|
1039
|
+
if (!isDeepStrictEqual(summary.recovery.protectedFiles, protectedFiles)) {
|
|
1040
|
+
throw new Error('recovery summary protection conflicts with its journal protection; refusing recovery rather than risk replay');
|
|
1041
|
+
}
|
|
1042
|
+
for (const entry of protectedFiles) {
|
|
1043
|
+
const abs = path.resolve(root, entry.target);
|
|
1044
|
+
let stat = null;
|
|
1045
|
+
try {
|
|
1046
|
+
stat = fs.statSync(abs);
|
|
1047
|
+
} catch {
|
|
1048
|
+
throw new Error(`completed file ${entry.target} is missing or changed since the parent run; refusing recovery`);
|
|
1049
|
+
}
|
|
1050
|
+
if (!stat.isFile()) throw new Error(`completed file ${entry.target} is missing or changed since the parent run; refusing recovery`);
|
|
1051
|
+
if (stat.nlink > 1) throw new Error(`completed file ${entry.target} has multiple hard links; manual review needed before recovery`);
|
|
1052
|
+
if (sha256Hex(fs.readFileSync(abs)) !== entry.sha256) {
|
|
1053
|
+
throw new Error(`completed file ${entry.target} changed since the parent run; refusing recovery`);
|
|
1054
|
+
}
|
|
1055
|
+
if (stat.dev !== entry.dev || stat.ino !== entry.ino) {
|
|
1056
|
+
throw new Error(`completed file ${entry.target} was replaced or aliased since the parent run; refusing recovery`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
// A failed tool only counts as resolved when the bytes still match the last
|
|
1060
|
+
// confirmation for that target (including inherited confirmations), or the
|
|
1061
|
+
// never-confirmed target stayed absent.
|
|
1062
|
+
for (const item of effects.failed) {
|
|
1063
|
+
const abs = path.resolve(root, item.target);
|
|
1064
|
+
const last = lastConfirmed.get(item.target) || inheritedByTarget.get(item.target);
|
|
1065
|
+
if (last) {
|
|
1066
|
+
let current = null;
|
|
1067
|
+
try {
|
|
1068
|
+
current = sha256Hex(fs.readFileSync(abs));
|
|
1069
|
+
} catch {
|
|
1070
|
+
throw new Error(`file ${item.target} for a failed action is unreadable; refusing recovery`);
|
|
1071
|
+
}
|
|
1072
|
+
if (current !== last.sha256) {
|
|
1073
|
+
throw new Error(`failed action left ${item.target} different from its last confirmed state; refusing recovery`);
|
|
1074
|
+
}
|
|
1075
|
+
} else if (fs.existsSync(abs)) {
|
|
1076
|
+
throw new Error(`failed action may have changed ${item.target}; refusing recovery rather than risk replay`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return { parentRunId: launch.runId, parentSummary: summary, protectedFiles };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// A parent receipt backs at most one child. The claim lives next to the
|
|
1083
|
+
// parent receipt, outside the pack root. Exclusive creation makes the single
|
|
1084
|
+
// child atomic; a claim with no child means the launcher crashed mid-claim
|
|
1085
|
+
// and needs manual review, never an automatic replay.
|
|
1086
|
+
function packRecoveryClaimPath(parentReceiptPath) {
|
|
1087
|
+
const dir = path.dirname(path.resolve(parentReceiptPath));
|
|
1088
|
+
return path.join(dir, `${path.basename(parentReceiptPath, '.json')}.claim.json`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function claimPackRecoveryParent(parentReceiptPath, parentRunId) {
|
|
1092
|
+
const claimPath = packRecoveryClaimPath(parentReceiptPath);
|
|
1093
|
+
const claim = {
|
|
1094
|
+
version: 1,
|
|
1095
|
+
parentReceipt: path.resolve(parentReceiptPath),
|
|
1096
|
+
parentRunId,
|
|
1097
|
+
claimedAt: new Date().toISOString(),
|
|
1098
|
+
childReceipt: null,
|
|
1099
|
+
};
|
|
1100
|
+
let descriptor = null;
|
|
1101
|
+
try {
|
|
1102
|
+
descriptor = fs.openSync(claimPath, 'wx', 0o600);
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
if (error && error.code === 'EEXIST') {
|
|
1105
|
+
let existing = null;
|
|
1106
|
+
try {
|
|
1107
|
+
existing = JSON.parse(fs.readFileSync(claimPath, 'utf8'));
|
|
1108
|
+
} catch {
|
|
1109
|
+
existing = null;
|
|
1110
|
+
}
|
|
1111
|
+
if (!existing || typeof existing !== 'object') {
|
|
1112
|
+
throw new Error(`a recovery claim for this receipt is unreadable; manual review needed at ${claimPath}`);
|
|
1113
|
+
}
|
|
1114
|
+
if (existing.childReceipt) {
|
|
1115
|
+
throw new Error(`this receipt already recovered as ${existing.childReceipt}; recover from that child instead`);
|
|
1116
|
+
}
|
|
1117
|
+
throw new Error(`a recovery claim is already in progress for this receipt; manual review needed at ${claimPath}`);
|
|
1118
|
+
}
|
|
1119
|
+
throw error;
|
|
1120
|
+
}
|
|
1121
|
+
try {
|
|
1122
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(claim, null, 2)}\n`);
|
|
1123
|
+
} finally {
|
|
1124
|
+
fs.closeSync(descriptor);
|
|
1125
|
+
}
|
|
1126
|
+
return claimPath;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function confirmPackRecoveryClaim(claimPath, childReceiptPath) {
|
|
1130
|
+
const claim = JSON.parse(fs.readFileSync(claimPath, 'utf8'));
|
|
1131
|
+
claim.childReceipt = path.resolve(childReceiptPath);
|
|
1132
|
+
fs.writeFileSync(claimPath, `${JSON.stringify(claim, null, 2)}\n`);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
312
1135
|
function defaultProcessExists(pid) {
|
|
313
1136
|
try {
|
|
314
1137
|
process.kill(pid, 0);
|
|
@@ -391,6 +1214,8 @@ function beginPackRunReceipt(packDir, manifest, policy, options = {}) {
|
|
|
391
1214
|
},
|
|
392
1215
|
...(options.operatorInput ? { operatorInput: options.operatorInput } : {}),
|
|
393
1216
|
approvalMode: options.trust ? 'pre-approved-within-declared-ceiling' : 'prompt-within-declared-ceiling',
|
|
1217
|
+
journal: { version: PACK_JOURNAL_VERSION, supported: true },
|
|
1218
|
+
recovery: sanitizeLaunchRecovery(options.recovery),
|
|
394
1219
|
requestedCapabilities: policy.requested,
|
|
395
1220
|
grantedCapabilities: policy.grantedCapabilities,
|
|
396
1221
|
grantedTools: policy.tools,
|
|
@@ -629,16 +1454,42 @@ function runHook(mode, rawInput) {
|
|
|
629
1454
|
if (!decision.allowed) {
|
|
630
1455
|
return denyPreToolUse(env, input, decision.reason, now);
|
|
631
1456
|
}
|
|
1457
|
+
// Denied mutations return above, so they never leave a pending intent.
|
|
1458
|
+
const protection = checkProtectedMutation(env, input);
|
|
1459
|
+
if (protection && protection.denied) {
|
|
1460
|
+
return denyPreToolUse(env, input, protection.reason, now);
|
|
1461
|
+
}
|
|
1462
|
+
journalFileIntent(env, input);
|
|
632
1463
|
return null;
|
|
633
1464
|
}
|
|
634
1465
|
|
|
635
1466
|
if (mode === 'used' || mode === 'failed') {
|
|
636
|
-
|
|
1467
|
+
const entry = {
|
|
637
1468
|
event: mode,
|
|
638
1469
|
at: now,
|
|
639
1470
|
tool: input.tool_name || null,
|
|
640
1471
|
capability: toolCapability(input.tool_name, env.grantedCapabilities),
|
|
641
|
-
}
|
|
1472
|
+
};
|
|
1473
|
+
if (FILE_MUTATION_TOOLS.has(input.tool_name)) {
|
|
1474
|
+
entry.tool_use_id = hookToolUseId(input);
|
|
1475
|
+
try {
|
|
1476
|
+
entry.target = journalRelativeTarget(input.tool_name, input.tool_input || {}, env.root);
|
|
1477
|
+
if (entry.target) {
|
|
1478
|
+
const identity = readFileIdentity(path.resolve(fs.realpathSync(env.root), entry.target));
|
|
1479
|
+
entry.targetPresent = identity.present;
|
|
1480
|
+
if (identity.present && identity.isFile) {
|
|
1481
|
+
entry.fileSha256 = identity.sha256;
|
|
1482
|
+
entry.dev = identity.dev;
|
|
1483
|
+
entry.ino = identity.ino;
|
|
1484
|
+
entry.nlink = identity.nlink;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
} catch {
|
|
1488
|
+
// A confirmation that cannot read its file stays deliberately thin;
|
|
1489
|
+
// pairing treats it as unresolved rather than confirmed.
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
appendReceiptEvent(env.eventsPath, entry);
|
|
642
1493
|
} else if (mode === 'session-end') {
|
|
643
1494
|
const reason = CLAUDE_SESSION_END_REASONS.has(input.reason) ? input.reason : 'other';
|
|
644
1495
|
appendReceiptEvent(env.eventsPath, { event: 'session-end', at: now, reason });
|
|
@@ -656,6 +1507,9 @@ async function runHookAsync(mode, rawInput, options = {}) {
|
|
|
656
1507
|
const now = new Date().toISOString();
|
|
657
1508
|
const fileDecision = decidePreToolUse(input, env);
|
|
658
1509
|
if (!fileDecision.allowed) return denyPreToolUse(env, input, fileDecision.reason, now);
|
|
1510
|
+
const protection = checkProtectedMutation(env, input);
|
|
1511
|
+
if (protection && protection.denied) return denyPreToolUse(env, input, protection.reason, now);
|
|
1512
|
+
journalFileIntent(env, input);
|
|
659
1513
|
const webDecision = await enforcePublicWeb(input, options);
|
|
660
1514
|
if (!webDecision.allowed) return denyPreToolUse(env, input, webDecision.reason, now);
|
|
661
1515
|
return null;
|
|
@@ -692,4 +1546,8 @@ module.exports = {
|
|
|
692
1546
|
enforcePublicWeb,
|
|
693
1547
|
runHook,
|
|
694
1548
|
runHookAsync,
|
|
1549
|
+
assessPackRecoveryJournal,
|
|
1550
|
+
packRecoveryClaimPath,
|
|
1551
|
+
claimPackRecoveryParent,
|
|
1552
|
+
confirmPackRecoveryClaim,
|
|
695
1553
|
};
|