@eyeauras/insight-cli 0.5.4 → 0.5.5

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.
Files changed (86) hide show
  1. package/.cli-package-build.json +1 -1
  2. package/SKILL.md +1 -1
  3. package/dist/src/analysis-storage.d.ts +2 -1
  4. package/dist/src/analysis-storage.js +71 -7
  5. package/dist/src/context-diagnostics.d.ts +1 -1
  6. package/dist/src/context-diagnostics.js +1 -1
  7. package/dist/src/contexts.d.ts +1 -0
  8. package/dist/src/contexts.js +29 -3
  9. package/dist/src/ghidra-processes.js +6 -1
  10. package/dist/src/tool-runtime/java-arguments.js +2 -2
  11. package/dist/src/tool-runtime/native-process-proof.d.ts +2 -0
  12. package/dist/src/tool-runtime/native-process-proof.js +26 -0
  13. package/dist/src/tool-runtime/run.js +3 -3
  14. package/dist/src/tool-runtime/store.d.ts +2 -0
  15. package/dist/src/tool-runtime/store.js +26 -8
  16. package/dist/src/tool-runtime/supervisor.js +4 -4
  17. package/package.json +1 -1
  18. package/references/recovery.md +17 -0
  19. package/references/storage.md +11 -0
  20. package/runtime/native/process-identity-darwin-arm64 +0 -0
  21. package/runtime/native/process-identity-darwin.c +47 -0
  22. package/runtime/native/process-identity.provenance.json +18 -0
  23. package/skill-bundle/insight-artifacts/SKILL.md +1 -1
  24. package/skill-bundle/insight-artifacts/references/recovery.md +17 -0
  25. package/skill-bundle/insight-artifacts/references/storage.md +11 -0
  26. package/skill-bundle/insight-binary/SKILL.md +1 -1
  27. package/skill-bundle/insight-capture/SKILL.md +1 -1
  28. package/skill-bundle/insight-cli/SKILL.md +1 -1
  29. package/skill-bundle/insight-cli/references/recovery.md +17 -0
  30. package/skill-bundle/insight-cli/references/storage.md +11 -0
  31. package/skill-bundle/insight-die/SKILL.md +1 -1
  32. package/skill-bundle/insight-dnspy/SKILL.md +1 -1
  33. package/skill-bundle/insight-dotnet-dump/SKILL.md +1 -1
  34. package/skill-bundle/insight-dotnet-stack/SKILL.md +1 -1
  35. package/skill-bundle/insight-dotnet-trace/SKILL.md +1 -1
  36. package/skill-bundle/insight-frida/SKILL.md +1 -1
  37. package/skill-bundle/insight-ghidra/SKILL.md +4 -1
  38. package/skill-bundle/insight-ghidra/references/recovery.md +17 -0
  39. package/skill-bundle/insight-ghidra/references/storage.md +11 -0
  40. package/skill-bundle/insight-ilspy/SKILL.md +1 -1
  41. package/skill-bundle/insight-interop/SKILL.md +1 -1
  42. package/skill-bundle/insight-lldb/SKILL.md +1 -1
  43. package/skill-bundle/insight-llvm/SKILL.md +1 -1
  44. package/skill-bundle/insight-managed-heap/SKILL.md +1 -1
  45. package/skill-bundle/insight-managed-metadata/SKILL.md +1 -1
  46. package/skill-bundle/insight-mitmproxy/SKILL.md +1 -1
  47. package/skill-bundle/insight-overview/SKILL.md +1 -1
  48. package/skill-bundle/insight-procmon/SKILL.md +1 -1
  49. package/skill-bundle/insight-re/SKILL.md +1 -1
  50. package/skill-bundle/insight-re/references/recovery.md +17 -0
  51. package/skill-bundle/insight-re/references/storage.md +11 -0
  52. package/skill-bundle/insight-sevenzip/SKILL.md +1 -1
  53. package/skill-bundle/insight-strace/SKILL.md +1 -1
  54. package/skill-bundle/insight-tshark/SKILL.md +1 -1
  55. package/skill-bundle/insight-windbg/SKILL.md +1 -1
  56. package/skill-bundle/manifest.json +1 -1
  57. package/skills/insight-artifacts/SKILL.md +1 -1
  58. package/skills/insight-artifacts/references/recovery.md +17 -0
  59. package/skills/insight-artifacts/references/storage.md +11 -0
  60. package/skills/insight-binary/SKILL.md +1 -1
  61. package/skills/insight-capture/SKILL.md +1 -1
  62. package/skills/insight-die/SKILL.md +1 -1
  63. package/skills/insight-dnspy/SKILL.md +1 -1
  64. package/skills/insight-dotnet-dump/SKILL.md +1 -1
  65. package/skills/insight-dotnet-stack/SKILL.md +1 -1
  66. package/skills/insight-dotnet-trace/SKILL.md +1 -1
  67. package/skills/insight-frida/SKILL.md +1 -1
  68. package/skills/insight-ghidra/SKILL.md +4 -1
  69. package/skills/insight-ghidra/references/recovery.md +17 -0
  70. package/skills/insight-ghidra/references/storage.md +11 -0
  71. package/skills/insight-ilspy/SKILL.md +1 -1
  72. package/skills/insight-interop/SKILL.md +1 -1
  73. package/skills/insight-lldb/SKILL.md +1 -1
  74. package/skills/insight-llvm/SKILL.md +1 -1
  75. package/skills/insight-managed-heap/SKILL.md +1 -1
  76. package/skills/insight-managed-metadata/SKILL.md +1 -1
  77. package/skills/insight-mitmproxy/SKILL.md +1 -1
  78. package/skills/insight-overview/SKILL.md +1 -1
  79. package/skills/insight-procmon/SKILL.md +1 -1
  80. package/skills/insight-re/SKILL.md +1 -1
  81. package/skills/insight-re/references/recovery.md +17 -0
  82. package/skills/insight-re/references/storage.md +11 -0
  83. package/skills/insight-sevenzip/SKILL.md +1 -1
  84. package/skills/insight-strace/SKILL.md +1 -1
  85. package/skills/insight-tshark/SKILL.md +1 -1
  86. package/skills/insight-windbg/SKILL.md +1 -1
@@ -1 +1 @@
1
- {"version":1,"artifact":"7bea52ce544d1aaddb45588523b18cd693135aaaea9d4e02564623ad8f2b127e"}
1
+ {"version":1,"artifact":"338d2246eeab5c3a4de372ad6e77d505601a39dad11aea43d3627988afd14ba2"}
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: insight-cli
3
3
  description: Choose and prepare reverse-engineering capabilities from the insight-cli toolbox for binary, managed-code and runtime investigations. Use to discover the appropriate tool or its packaged skill before installing native runtimes.
4
4
  ---
5
5
 
6
- # insight-cli 0.5.4
6
+ # insight-cli 0.5.5
7
7
 
8
8
  If local help/status reports AppData or IPC access denied, retry that **read-only probe**
9
9
  with the agent host's normal permission escalation, then use that route for later authorized
@@ -115,7 +115,8 @@ export declare class AnalysisStorage {
115
115
  }>;
116
116
  private checkCopy;
117
117
  private continue;
118
- resume(c: CommandContext): Promise<{
118
+ private repairBinding;
119
+ resume(c: CommandContext, repairProfile?: boolean): Promise<{
119
120
  state: "rolled_back";
120
121
  id: string;
121
122
  directory: string;
@@ -251,8 +251,14 @@ export class AnalysisStorage {
251
251
  fail('busy', 'Finish active commands before storage migration.');
252
252
  this.changing.add(key);
253
253
  try {
254
- const processes = (await listProcesses(key)).filter(p => !['exited', 'failed'].includes(p.state));
255
- const native = await runtimeProcesses(key);
254
+ let processes, native;
255
+ try {
256
+ processes = (await listProcesses(key)).filter(p => !['exited', 'failed'].includes(p.state));
257
+ native = await runtimeProcesses(key);
258
+ }
259
+ catch (error) {
260
+ throw new CliError('Cannot verify storage process inventory. Inspect storage show and preserve legacy compatibility paths before retrying.', { code: 'storage.inventory', cause: error, next: [['storage', 'show']] });
261
+ }
256
262
  if (processes.length || native.length)
257
263
  throw new CliError('Close the profile contexts and stop its owned server/processes before migration. Unverified lifetimes also block copying.', { code: 'storage.busy', result: { processes, native }, next: [['tools', 'processes']] });
258
264
  await this.release(c);
@@ -354,8 +360,14 @@ export class AnalysisStorage {
354
360
  return this.restore(c, j);
355
361
  if (j.state === 'cleaning')
356
362
  return this.finishCleanup(c, j);
357
- if (terminal(j.state) && !(j.automatic && j.state === 'committed'))
358
- return this.show(c);
363
+ if (terminal(j.state) && !(j.automatic && j.state === 'committed')) {
364
+ const selected = { ...c, profile: await this.store(c).get(c.profile.name) };
365
+ await this.validate(selected);
366
+ const status = await this.show(selected);
367
+ if (status.availability !== 'available')
368
+ throw new CliError('Completed migration storage is unavailable. Inspect storage show before retrying.', { code: 'storage.unavailable', next: [['storage', 'show']] });
369
+ return status;
370
+ }
359
371
  if (j.makeDefault && !j.defaultApplied) {
360
372
  const base = dirname(dirname(j.destination));
361
373
  if (await canonicalFuture(base) !== base)
@@ -535,8 +547,60 @@ export class AnalysisStorage {
535
547
  }
536
548
  return { state: j.state, id: j.id, directory: j.destination, manifest: j.manifest, sourceRetained: true, defaultBaseDirectory: storageDefault(c)?.base ?? null, installationDefault: j.makeDefault ? (j.defaultSuperseded ? 'superseded' : 'configured') : 'unchanged', rollback: 'storage rollback', cleanup: 'storage cleanup' };
537
549
  }
538
- async resume(c) { return this.exclusive(c, async () => { const j = await this.journal(c, true); if (!j)
539
- fail('missing', 'No migration journal exists.'); return this.continue(c, j); }); }
550
+ async repairBinding(c, j) {
551
+ if (!['committed', 'cleaned'].includes(j.state))
552
+ fail('repair', 'Profile repair requires a completed migration. Resume the pending transaction without --repair-profile.');
553
+ if (await directoryIdentity(j.source) !== j.sourceIdentity || await canonicalFuture(j.destination) !== j.destination || !j.targetIdentity || await directoryIdentity(j.destination) !== j.targetIdentity)
554
+ fail('binding', 'Migration source or destination identity changed. Preserve both copies; profile repair was refused.');
555
+ if (await directoryIdentity(j.destinationAnchor) !== j.destinationIdentity)
556
+ fail('volume', 'Migration destination volume changed. Profile repair was refused.');
557
+ if (j.defaultBinding)
558
+ validateStorageDefault(c, j.defaultBinding);
559
+ if (j.defaultBaseIdentity && await directoryIdentity(dirname(dirname(j.destination))) !== j.defaultBaseIdentity)
560
+ fail('volume', 'Storage base changed. Profile repair was refused.');
561
+ const marker = await readJson(join(j.destination, '.insight-analysis.json'));
562
+ if (marker?.schema !== 1 || marker.id !== j.id || marker.binding !== j.binding || marker.profile !== j.profile)
563
+ fail('binding', 'Destination ownership does not match the retained migration. Profile repair was refused.');
564
+ for (const tree of analysisTrees) {
565
+ const target = join(j.destination, tree), alias = join(j.source, tree);
566
+ try {
567
+ if (await canonicalFuture(target) !== target || !(await lstat(target)).isDirectory() || !(await lstat(alias)).isSymbolicLink() || await realpath(alias) !== target)
568
+ fail('aliases', 'A migration compatibility path changed. Preserve the conflicting path and restore its recorded destination before profile repair.');
569
+ }
570
+ catch (error) {
571
+ if (error instanceof CliError)
572
+ throw error;
573
+ throw new CliError('Cannot verify a migration compatibility path. Preserve both copies before profile repair.', { code: 'storage.aliases', cause: error });
574
+ }
575
+ }
576
+ const setting = c.profile.values.analysisDirectory;
577
+ if (setting !== undefined && setting !== null && setting !== j.previous && setting !== j.source && setting !== j.destination)
578
+ fail('configuration', 'The profile selects a different storage location. Preserve it; automatic profile repair was refused.');
579
+ }
580
+ async resume(c, repairProfile = false) {
581
+ const j = await this.journal(c, true);
582
+ if (!j)
583
+ fail('missing', 'No migration journal exists.');
584
+ // Diagnose a terminal profile mismatch before scanning legacy native paths.
585
+ if (['committed', 'cleaned'].includes(j.state)) {
586
+ if (repairProfile)
587
+ await this.repairBinding(c, j);
588
+ else if (analysisDirectory(c) !== j.destination)
589
+ throw new CliError('Factory profile and completed migration differ. Verify and restore the profile with storage resume --repair-profile.', { code: 'storage.configuration', next: [['storage', 'resume', '--repair-profile']] });
590
+ }
591
+ else if (repairProfile)
592
+ fail('repair', 'Profile repair requires a completed migration. Resume without --repair-profile.');
593
+ return this.exclusive(c, async () => {
594
+ if (repairProfile) {
595
+ c = { ...c, profile: await this.store(c).get(c.profile.name) };
596
+ await this.repairBinding(c, j);
597
+ await this.store(c).set(c.profile.name, { analysisDirectory: j.destination });
598
+ await sync(join(c.appArguments.RoamingAppDataDirectory, 'profiles.json'));
599
+ c = { ...c, profile: await this.store(c).get(c.profile.name) };
600
+ }
601
+ return this.continue(c, j);
602
+ });
603
+ }
540
604
  async rollback(c) {
541
605
  return this.exclusive(c, async () => {
542
606
  const j = await this.journal(c, true);
@@ -650,7 +714,7 @@ export class AnalysisStorage {
650
714
  command('default', 'Set the installation storage base for new profiles; preserve existing data locations', ({ options: o }, c) => this.setDefault(c, o.directory), { permission: Permission.Update, options: [directory] }),
651
715
  command('configure', 'Choose storage for an empty profile and subsequent new profiles', ({ options: o }, c) => this.migrate(c, o.directory, false, true, Boolean(o.profileOnly)), { permission: Permission.Update, options: [directory, profileOnly] }),
652
716
  command('migrate', 'Copy and verify all analysis data, retain source, then switch storage', ({ options: o }, c) => this.migrate(c, o.directory, Boolean(o.dryRun), false, Boolean(o.profileOnly)), { permission: Permission.Update, options: [directory, profileOnly, { flags: '--dry-run', description: 'Read-only path, capacity and quiescence preflight; create no migration' }] }),
653
- command('resume', 'Resume the retained copy/activation or rollback without replaying research', (_i, c) => this.resume(c), { permission: Permission.Update }),
717
+ command('resume', 'Resume migration, or explicitly repair its completed profile binding', ({ options: o }, c) => this.resume(c, Boolean(o.repairProfile)), { permission: Permission.Update, options: [{ flags: '--repair-profile', description: 'Restore a completed migration setting after verifying destination ownership and compatibility paths' }] }),
654
718
  command('rollback', 'Restore retained source; refuse after any destination content changes', (_i, c) => this.rollback(c), { permission: Permission.Update }),
655
719
  command('cleanup', 'Delete verified retained source copies; retain compatibility paths permanently', (_i, c) => this.cleanup(c), { permission: 'Destructive' }),
656
720
  ]);
@@ -1,7 +1,7 @@
1
1
  import { type CommandContext, type CommandDefinition } from '@eyeauras/cli-factory';
2
2
  import { type Research } from './research.js';
3
3
  import type { Workspace, Scope } from './workspace.js';
4
- export type StopReason = 'explicit_close' | 'view_replaced' | 'mode_changed' | 'owner_dispose' | 'startup_failed' | 'temporary_complete' | 'legacy_cleanup' | 'unknown';
4
+ export type StopReason = 'explicit_close' | 'view_replaced' | 'mode_changed' | 'owner_dispose' | 'startup_failed' | 'temporary_complete' | 'legacy_cleanup' | 'backend_unresponsive' | 'unknown';
5
5
  type Event = {
6
6
  at: string;
7
7
  event: 'start' | 'restore' | 'stop';
@@ -7,7 +7,7 @@ import { object, fail, stable } from './model.js';
7
7
  import { selectedOptions } from './arguments.js';
8
8
  const writes = new Map();
9
9
  const journal = (workspace, context) => join(workspace.directory, 'diagnostics', 'lifecycle', context + '.json');
10
- const reasons = new Set(['explicit_close', 'view_replaced', 'mode_changed', 'owner_dispose', 'startup_failed', 'temporary_complete', 'legacy_cleanup', 'unknown', 'native_launch', 'saved_snapshot']);
10
+ const reasons = new Set(['explicit_close', 'view_replaced', 'mode_changed', 'owner_dispose', 'startup_failed', 'temporary_complete', 'legacy_cleanup', 'unknown', 'native_launch', 'saved_snapshot', 'backend_unresponsive']);
11
11
  async function events(path) {
12
12
  try {
13
13
  const rows = JSON.parse(await readFile(path, 'utf8'));
@@ -62,6 +62,7 @@ export declare class GhidraContexts {
62
62
  private model;
63
63
  private privateCopy;
64
64
  open(scope: Scope, request?: string): Promise<GhidraContext>;
65
+ private retireUnresponsive;
65
66
  private selected;
66
67
  observation(scope: Scope): Promise<{
67
68
  incarnation: string;
@@ -429,7 +429,7 @@ export class GhidraContexts {
429
429
  }
430
430
  async open(scope, request) {
431
431
  const context = this.workspace.context(scope), target = this.workspace.target(scope.target);
432
- if (this.workspace.unresolved(scope).some(intent => intent.id !== request))
432
+ if (this.workspace.unresolved(scope).some(intent => intent.id !== request && intent.operation !== 'ghidra.open'))
433
433
  fail('blocked', 'This context retains an unresolved operation; reconcile its recorded outcome before continuing.');
434
434
  let binding;
435
435
  try {
@@ -458,8 +458,15 @@ export class GhidraContexts {
458
458
  if (binding && !stopped && alive(binding.pid)) {
459
459
  if (binding.context !== context.id)
460
460
  fail('binding', 'Native process belongs to a different working context.');
461
- await this.verified(binding, target);
462
- return context;
461
+ try {
462
+ await this.verified(binding, target);
463
+ return context;
464
+ }
465
+ catch (error) {
466
+ if (!await this.retireUnresponsive(scope, binding, error))
467
+ throw error;
468
+ stopped = true;
469
+ }
463
470
  }
464
471
  if (binding?.mode === 'gui')
465
472
  await this.debuggerReleased(binding);
@@ -486,6 +493,25 @@ export class GhidraContexts {
486
493
  return this.workspace.saveContext(scope, context.generation, { state: 'ready', baseRevision: model.sharedRevision,
487
494
  baseSnapshot: snapshot, workingSnapshot: snapshot });
488
495
  }
496
+ async retireUnresponsive(scope, binding, error) {
497
+ const failure = error;
498
+ if (!['coordinator.timeout', 'coordinator.transport'].includes(failure.code ?? '') || failure.result?.operation !== '/mcp/instance_info' ||
499
+ binding.mode !== 'headless' || !binding.toolProcessId)
500
+ return false;
501
+ const context = this.workspace.context(scope);
502
+ if (!context.workingSnapshot || context.debugger?.connected || this.workspace.activePublication(scope.target) ||
503
+ this.workspace.unresolved(scope).some(intent => intent.operation !== 'ghidra.open'))
504
+ return false;
505
+ // Verify acknowledged bytes before retiring anything. Unknown mutations stay blocked.
506
+ await this.workspace.artifacts.verify(context.workingSnapshot);
507
+ const row = await processStatus(this.c.appArguments.AppDataDirectory, binding.toolProcessId, r => verifyGhidraProcess(r, this.c));
508
+ if (row.state !== 'running' || row.pid !== binding.pid || row.tool !== 'ghidra' || row.role !== 'context' || row.session !== scope.session ||
509
+ row.target !== scope.target || row.installation !== binding.runtimeFingerprint || row.metadata?.contextRoot !== binding.root)
510
+ return false;
511
+ await terminateProcess(this.c.appArguments.AppDataDirectory, binding.toolProcessId, true, r => verifyGhidraProcess(r, this.c));
512
+ await this.stopRecord({ binding, http: new HttpBridge(binding.endpoint) }, false, true, 'backend_unresponsive');
513
+ return true;
514
+ }
489
515
  async selected(scope) {
490
516
  const context = this.workspace.context(scope);
491
517
  const binding = JSON.parse(await readFile(join(this.directory(), 'context', context.id, 'current.json'), 'utf8'));
@@ -1,3 +1,4 @@
1
+ import { nativeProcessProof } from './tool-runtime/native-process-proof.js';
1
2
  import { sameDirectory, ownedDirectory, projectPathWithin } from './directory-identity.js';
2
3
  import { runtimeSelection } from './runtime-selection.js';
3
4
  import { join, resolve, relative, sep, dirname } from 'node:path';
@@ -52,7 +53,11 @@ export async function verifyGhidraProcess(r, c) {
52
53
  const root = await ownedPath(c, r.metadata.contextRoot), binding = JSON.parse(await readFile(join(root, 'process.json'), 'utf8'));
53
54
  if (!sameDirectory(binding.root, root) || binding.pid !== r.pid || binding.runtimeFingerprint !== r.installation || binding.target !== r.target)
54
55
  return false;
55
- const token = await c.secrets.require(binding.tokenSecret), info = await new HttpBridge(binding.endpoint, c.fetch, undefined, token, 1500).instance();
56
+ const token = await c.secrets.require(binding.tokenSecret);
57
+ // A headless JVM may be unable to serve HTTP while the kernel still proves its token, executable and private context root.
58
+ if (binding.mode === 'headless' && await nativeProcessProof(r.pid, r.executable, root, token))
59
+ return true;
60
+ const info = await new HttpBridge(binding.endpoint, c.fetch, undefined, token, 1500).instance();
56
61
  return info.pid === r.pid && info.tcp_port === Number(new URL(binding.endpoint).port) && projectPathWithin(info.project_path, binding.root);
57
62
  }
58
63
  return undefined;
@@ -1,4 +1,4 @@
1
- import { writeFile } from 'node:fs/promises';
1
+ import { writeFile, realpath } from 'node:fs/promises';
2
2
  import { join, win32 } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
@@ -36,7 +36,7 @@ export async function javaClasspathArguments(root, tool, executable, args, platf
36
36
  const index = args.findIndex(arg => arg === '-cp' || arg === '-classpath' || arg === '--class-path');
37
37
  if (index < 0 || args[index + 1] === undefined)
38
38
  return args;
39
- const path = join(root, 'java-classpath.args');
39
+ const path = join(await realpath(root), 'java-classpath.args');
40
40
  const content = '-classpath\n' + quoteJavaArgument(args[index + 1]) + '\n';
41
41
  const bytes = /^java\.exe$/i.test(win32.basename(executable)) && process.platform === 'win32' ? await encodeJavaLauncherFile(content) : Buffer.from(content, 'utf8');
42
42
  await writeFile(path, bytes, { flag: 'wx', mode: 0o600 });
@@ -0,0 +1,2 @@
1
+ /** Independent kernel observation; no request enters the possibly wedged JVM. */
2
+ export declare function nativeProcessProof(pid: number, executable: string, cwd: string, token: string): Promise<boolean>;
@@ -0,0 +1,26 @@
1
+ import { readFile, realpath } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { createHash, timingSafeEqual } from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ const exec = promisify(execFile);
7
+ /** Independent kernel observation; no request enters the possibly wedged JVM. */
8
+ export async function nativeProcessProof(pid, executable, cwd, token) {
9
+ if (process.platform !== 'darwin' || !Number.isSafeInteger(pid) || pid < 1)
10
+ return false;
11
+ try {
12
+ const base = new URL('../../../runtime/native/', import.meta.url), path = fileURLToPath(new URL('process-identity-darwin-' + process.arch, base));
13
+ const provenance = JSON.parse(await readFile(new URL('process-identity.provenance.json', base), 'utf8'));
14
+ if (provenance.platform !== 'darwin' || provenance.architecture !== process.arch || createHash('sha256').update(await readFile(path)).digest('hex') !== provenance.sha256)
15
+ return false;
16
+ const result = await exec(path, [String(pid)], { timeout: 3000, maxBuffer: 16384, env: { PATH: '/usr/bin:/bin' } }), row = JSON.parse(result.stdout);
17
+ if (row.pid !== pid || row.uid !== process.geteuid?.() || !Number.isSafeInteger(row.birthSeconds) || row.birthSeconds <= 0 || !Number.isSafeInteger(row.birthMicroseconds) || row.birthMicroseconds < 0 || row.birthMicroseconds >= 1000000 || typeof row.tokenSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(row.tokenSha256))
18
+ return false;
19
+ if (await realpath(row.executable) !== await realpath(executable) || await realpath(row.root) !== await realpath(cwd))
20
+ return false;
21
+ return timingSafeEqual(Buffer.from(row.tokenSha256, 'hex'), createHash('sha256').update(token).digest());
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
@@ -1,9 +1,9 @@
1
- import { mkdir, writeFile } from 'node:fs/promises';
1
+ import { writeFile } from 'node:fs/promises';
2
2
  import { join, isAbsolute, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { performance } from 'node:perf_hooks';
5
5
  import { CliError } from '@eyeauras/cli-factory';
6
- import { atomicWrite, withToolUse, toolRoot, readJson } from './store.js';
6
+ import { atomicWrite, withToolUse, toolRoot, readJson, prepareRunRoot } from './store.js';
7
7
  import { launchProcess } from './supervisor.js';
8
8
  import { digest } from '../model.js';
9
9
  import { deadlineLaunch } from './deadline.js';
@@ -23,7 +23,7 @@ export async function runTool(c, spec) {
23
23
  const started = performance.now(), limit = spec.maxOutputBytes ?? 4 * 1024 ** 2;
24
24
  let child, outcome, truncated = false, bytes = 0;
25
25
  const stdout = [], stderr = [], record = { schema: 1, id, tool: spec.tool, role: spec.role, installation: spec.installation ?? 'probe', state: 'starting', executable: spec.executable, createdAt: new Date().toISOString(), root, ownerPid: process.pid, profileBinding: digest(resolve(directory)), gated: process.platform !== 'win32' && typeof process.execve === 'function', phase: 'reserved', ...(spec.session ? { session: spec.session } : {}), ...(spec.target ? { target: spec.target } : {}) };
26
- const lease = await withToolUse(directory, spec.tool, async () => { await mkdir(root, { recursive: true, mode: 0o700 }); }, c.signal);
26
+ const lease = await withToolUse(directory, spec.tool, async () => { await prepareRunRoot(directory, id); }, c.signal);
27
27
  const environment = { PATH: c.environment.PATH ?? process.env.PATH, HOME: root, TMPDIR: root, ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, ComSpec: process.env.ComSpec, TEMP: root, TMP: root, USERPROFILE: root, LOCALAPPDATA: root, APPDATA: root } : {}), ...spec.environment };
28
28
  const secrets = Object.entries(environment).filter(([key, value]) => /TOKEN|PASSWORD|SECRET|KEY/i.test(key) && value && value.length >= 4).map(([, v]) => v);
29
29
  const redact = (value) => secrets.reduce((text, secret) => text.split(secret).join('[redacted]'), value);
@@ -1,4 +1,6 @@
1
1
  export declare function atomicWrite(path: string, value: unknown): Promise<void>;
2
+ /** Create new run folders through the physical runtime, retaining logical receipt identity. */
3
+ export declare function prepareRunRoot(directory: string, id: string): Promise<void>;
2
4
  export declare function readJson<T>(path: string): Promise<T | undefined>;
3
5
  export declare function toolRoot(directory: string, tool: string): string;
4
6
  /** CLI clients already share Factory's elected owner. Do not create another IPC/lease protocol. */
@@ -1,13 +1,14 @@
1
1
  import { mkdir, open, rename, rm, readFile, readdir, lstat, realpath } from 'node:fs/promises';
2
- import { createReadStream } from 'node:fs';
3
- import { dirname, join, resolve, relative, sep } from 'node:path';
2
+ import { createReadStream, existsSync } from 'node:fs';
3
+ import { dirname, basename, join, resolve, relative, sep } from 'node:path';
4
4
  import { randomUUID, createHash } from 'node:crypto';
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import { CliError } from '@eyeauras/cli-factory';
7
7
  import { fail } from '../model.js';
8
8
  export async function atomicWrite(path, value) {
9
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
10
- const temporary = path + '.new-' + randomUUID(), file = await open(temporary, 'wx', 0o600);
9
+ const parent = await physicalDirectory(dirname(path)), target = join(parent, basename(path));
10
+ await mkdir(parent, { recursive: true, mode: 0o700 });
11
+ const temporary = target + '.new-' + randomUUID(), file = await open(temporary, 'wx', 0o600);
11
12
  try {
12
13
  await file.writeFile(JSON.stringify(value, null, 2) + '\n');
13
14
  await file.sync();
@@ -15,17 +16,34 @@ export async function atomicWrite(path, value) {
15
16
  finally {
16
17
  await file.close();
17
18
  }
18
- await rename(temporary, path);
19
+ await rename(temporary, target);
19
20
  if (process.platform !== 'win32') {
20
- const parent = await open(dirname(path), 'r');
21
+ const handle = await open(parent, 'r');
21
22
  try {
22
- await parent.sync();
23
+ await handle.sync();
23
24
  }
24
25
  finally {
25
- await parent.close();
26
+ await handle.close();
26
27
  }
27
28
  }
28
29
  }
30
+ async function physicalDirectory(path) {
31
+ try {
32
+ return await realpath(path);
33
+ }
34
+ catch (error) {
35
+ if (error.code !== 'ENOENT' || dirname(path) === path)
36
+ throw error;
37
+ return join(await physicalDirectory(dirname(path)), basename(path));
38
+ }
39
+ }
40
+ /** Create new run folders through the physical runtime, retaining logical receipt identity. */
41
+ export async function prepareRunRoot(directory, id) {
42
+ const runtime = join(directory, 'runtime');
43
+ if (!existsSync(runtime))
44
+ await mkdir(runtime, { recursive: true, mode: 0o700 });
45
+ await mkdir(join(await realpath(runtime), 'runs', id), { recursive: true, mode: 0o700 });
46
+ }
29
47
  export async function readJson(path) { try {
30
48
  return JSON.parse(await readFile(path, 'utf8'));
31
49
  }
@@ -1,13 +1,13 @@
1
1
  import { sameDirectory } from '../directory-identity.js';
2
2
  import { spawn } from 'node:child_process';
3
- import { access, mkdir, readdir } from 'node:fs/promises';
3
+ import { access, readdir } from 'node:fs/promises';
4
4
  import { constants } from 'node:fs';
5
5
  import { join, isAbsolute, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { setTimeout as delay } from 'node:timers/promises';
9
9
  import { CliError } from '@eyeauras/cli-factory';
10
- import { atomicWrite, readJson, toolRoot, withToolLock } from './store.js';
10
+ import { atomicWrite, readJson, toolRoot, withToolLock, prepareRunRoot } from './store.js';
11
11
  import { processIdentity } from './identity.js';
12
12
  import { javaClasspathArguments } from './java-arguments.js';
13
13
  import { digest } from '../model.js';
@@ -53,7 +53,7 @@ export async function launchProcess(directory, spec, executable, args, options =
53
53
  if (!/^[a-f0-9-]{36}$/.test(id))
54
54
  throw Error('Invalid process identity');
55
55
  const root = join(directory, 'runtime/runs', id), path = join(directory, 'runtime/processes', id + '.json');
56
- await mkdir(root, { recursive: true, mode: 0o700 });
56
+ await prepareRunRoot(directory, id);
57
57
  const gated = process.platform !== 'win32' && typeof process.execve === 'function';
58
58
  const record = { schema: 1, id, ...spec, state: 'starting', executable, root, ownerPid: process.pid, profileBinding: digest(resolve(directory)), gated, phase: 'reserved', createdAt: new Date().toISOString() };
59
59
  await atomicWrite(path, record);
@@ -273,7 +273,7 @@ export async function registerExistingProcess(directory, spec, executable, pid,
273
273
  const previous = await readJson(path);
274
274
  if (previous)
275
275
  return id;
276
- await mkdir(root, { recursive: true, mode: 0o700 });
276
+ await prepareRunRoot(directory, id);
277
277
  await atomicWrite(path, record);
278
278
  return id;
279
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eyeauras/insight-cli",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "description": "Agent-facing durable sessions and isolated Ghidra research contexts",
6
6
  "main": "./dist/src/index.js",
@@ -24,6 +24,23 @@ establishes that the old owned bootstrap worker has ended, then uses an authorit
24
24
  probe. An absent canonical item permits private preparation; an existing first version requires
25
25
  independent readback. The old project, intent and original session/target remain preserved.
26
26
 
27
+ ## Unresponsive headless backend
28
+
29
+ Continue the same session/target with ordinary `ghidra open` or a research read. When the
30
+ backend fails its identity preflight, the facade verifies the acknowledged private snapshot
31
+ and independently authenticates the exact worker before retiring it and restoring that snapshot.
32
+ This automatic retirement is limited to headless contexts without uncertain mutations, active
33
+ publication or a connected debugger. A failed health preflight is never permission to repeat a
34
+ script, save or publication. Journals, original projects and acknowledged pending edits remain.
35
+ Lifecycle diagnostics record `backend_unresponsive` followed by snapshot restoration.
36
+
37
+ On macOS ARM64 the packaged read-only kernel helper checks the same-user process, executable,
38
+ private root and digest of its unique context token without entering the JVM. It emits no token,
39
+ argument list or environment values. PID birth and profile bindings remain independently checked.
40
+ A missing/unreadable/mismatched proof stays blocked; never kill by port, basename or guessed PID.
41
+ Other platforms retain their existing ownership authentication; this does not claim equivalent
42
+ hang recovery where no independent proof is available. No compiler is required on installation.
43
+
27
44
  ## Interrupted save or close
28
45
 
29
46
  After restoring profile storage, inspect the selected session and target with
@@ -84,6 +84,17 @@ those links or their profile/source ancestors manually after migrating.
84
84
 
85
85
  ## Interruptions, uncertainty and retention
86
86
 
87
+ If a completed migration no longer agrees with the Factory profile setting, ordinary
88
+ commands and `storage resume` fail. After inspecting `storage show`, explicitly restore
89
+ the setting with `storage resume --repair-profile --json`. This Update operation verifies
90
+ the journal, source/destination identities, destination ownership and every compatibility
91
+ link before changing only `analysisDirectory`. Later destination research is preserved;
92
+ the old migration manifest is not used to roll it back. A different selected location,
93
+ missing volume or replaced compatibility path blocks repair. Preserve conflicting paths
94
+ and restore the recorded links before retrying; repair never deletes or merges them.
95
+ Process-inventory failures are reported as `storage.inventory` with the original cause
96
+ available under `--verbose`. A terminal unavailable migration is never a successful resume.
97
+
87
98
  ```sh
88
99
  insight-cli storage resume --json
89
100
  insight-cli storage rollback --json
@@ -0,0 +1,47 @@
1
+ /* MIT; first-party read-only process proof. Never prints argv or environment values. */
2
+ #include <libproc.h>
3
+ #include <sys/sysctl.h>
4
+ #include <sys/proc_info.h>
5
+ #include <sys/proc.h>
6
+ #include <CommonCrypto/CommonDigest.h>
7
+ #include <unistd.h>
8
+ #include <stdio.h>
9
+ #include <stdlib.h>
10
+ #include <string.h>
11
+ #include <errno.h>
12
+ static void json(const char *s) {
13
+ putchar('"'); for(const unsigned char *p=(const unsigned char *)s;*p;p++) {
14
+ if(*p=='"'||*p=='\\')printf("\\%c",*p); else if(*p<32)printf("\\u%04x",*p);else putchar(*p);
15
+ } putchar('"');
16
+ }
17
+ int main(int argc,char **argv) {
18
+ if(argc==2&&!strcmp(argv[1],"--version")){puts("insight-process-identity 1");return 0;}
19
+ char *end=NULL;errno=0;long value=argc==2?strtol(argv[1],&end,10):0;
20
+ if(errno||!end||*end||value<1||value>2147483647)return 64;
21
+ int pid=(int)value;struct proc_bsdinfo before,after;char executable[PROC_PIDPATHINFO_MAXSIZE];
22
+ if(proc_pidinfo(pid,PROC_PIDTBSDINFO,0,&before,sizeof before)!=sizeof before||before.pbi_uid!=geteuid()||before.pbi_status==SZOMB)return 11;
23
+ memset(executable,0,sizeof executable);int has_path=proc_pidpath(pid,executable,sizeof executable)>0;
24
+ char owned_root[PROC_PIDPATHINFO_MAXSIZE]={0};
25
+ int mib[3]={CTL_KERN,KERN_PROCARGS2,pid};size_t size=0;
26
+ if(sysctl(mib,3,NULL,&size,NULL,0)||size<sizeof(int)||size>2*1024*1024)return 13;
27
+ char *data=calloc(size+1,1);if(!data)return 70;
28
+ if(sysctl(mib,3,data,&size,NULL,0)){free(data);return 14;}
29
+ int nargs;memcpy(&nargs,data,sizeof nargs);if(nargs<1||nargs>65536){free(data);return 1;}
30
+ char *p=data+sizeof(int),*limit=data+size;size_t executable_length=strnlen(p,(size_t)(limit-p));
31
+ if(executable_length>=sizeof executable||p+executable_length>=limit){free(data);return 1;}
32
+ if(!has_path)memcpy(executable,p,executable_length+1);
33
+ while(p<limit&&*p)p++;while(p<limit&&!*p)p++;
34
+ for(int i=0;i<nargs&&p<limit;i++){while(p<limit&&*p)p++;if(p<limit)p++;}
35
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];int found=0;const char key[]="GHIDRA_MCP_AUTH_TOKEN=";
36
+ while(p<limit&&!*p)p++;
37
+ while(p<limit&&*p){size_t n=strnlen(p,(size_t)(limit-p));
38
+ if(n>sizeof(key)-1&&!memcmp(p,key,sizeof(key)-1)){if(found){free(data);return 1;}CC_SHA256(p+sizeof(key)-1,(CC_LONG)(n-sizeof(key)+1),digest);found=1;}
39
+ const char root_key[]="GHIDRA_MCP_FILE_ROOT=";
40
+ if(n>sizeof(root_key)-1&&!memcmp(p,root_key,sizeof(root_key)-1)){size_t length=n-sizeof(root_key)+1;if(owned_root[0]||length>=sizeof owned_root){free(data);return 1;}memcpy(owned_root,p+sizeof(root_key)-1,length);}
41
+ p+=n+1;
42
+ }
43
+ memset(data,0,size);free(data);
44
+ if(!found||!owned_root[0]||proc_pidinfo(pid,PROC_PIDTBSDINFO,0,&after,sizeof after)!=sizeof after||before.pbi_start_tvsec!=after.pbi_start_tvsec||before.pbi_start_tvusec!=after.pbi_start_tvusec||before.pbi_uid!=after.pbi_uid)return 15;
45
+ printf("{\"pid\":%d,\"uid\":%u,\"birthSeconds\":%llu,\"birthMicroseconds\":%llu,\"executable\":",pid,before.pbi_uid,before.pbi_start_tvsec,before.pbi_start_tvusec);json(executable);
46
+ printf(",\"root\":");json(owned_root);printf(",\"tokenSha256\":\"");for(size_t i=0;i<sizeof digest;i++)printf("%02x",digest[i]);puts("\"}");return 0;
47
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "schema": 1,
3
+ "license": "MIT",
4
+ "platform": "darwin",
5
+ "architecture": "arm64",
6
+ "compiler": "Apple clang version 17.0.0 (clang-1700.3.19.1)\nTarget: arm64-apple-darwin25.6.0\nThread model: posix\nInstalledDir: /Library/Developer/CommandLineTools/usr/bin",
7
+ "sourceSha256": "f147da90168123783593f06461c78aea9e4b6ac326b7bdd003da1b0765a06265",
8
+ "sha256": "8d7a15c925ba49b8bea5280c94c03c5a9e39ac33f408ad6dcc068af095e796f4",
9
+ "flags": [
10
+ "-arch",
11
+ "arm64",
12
+ "-mmacosx-version-min=13.0",
13
+ "-O2",
14
+ "-g0",
15
+ "-Werror",
16
+ "-Wno-deprecated-declarations"
17
+ ]
18
+ }
@@ -3,7 +3,7 @@ name: insight-artifacts
3
3
  description: Retain a completed reverse-engineering evidence file or directory as an ordinary checkpoint using insight-cli artifacts save. Use after Ghidra or any external tool; preserve existing project formats and source of truth.
4
4
  ---
5
5
 
6
- # Insight Artifacts 0.5.4
6
+ # Insight Artifacts 0.5.5
7
7
 
8
8
  If local help/status reports AppData or IPC access denied, retry that **read-only probe**
9
9
  with the agent host's normal permission escalation, then use that route for later authorized
@@ -24,6 +24,23 @@ establishes that the old owned bootstrap worker has ended, then uses an authorit
24
24
  probe. An absent canonical item permits private preparation; an existing first version requires
25
25
  independent readback. The old project, intent and original session/target remain preserved.
26
26
 
27
+ ## Unresponsive headless backend
28
+
29
+ Continue the same session/target with ordinary `ghidra open` or a research read. When the
30
+ backend fails its identity preflight, the facade verifies the acknowledged private snapshot
31
+ and independently authenticates the exact worker before retiring it and restoring that snapshot.
32
+ This automatic retirement is limited to headless contexts without uncertain mutations, active
33
+ publication or a connected debugger. A failed health preflight is never permission to repeat a
34
+ script, save or publication. Journals, original projects and acknowledged pending edits remain.
35
+ Lifecycle diagnostics record `backend_unresponsive` followed by snapshot restoration.
36
+
37
+ On macOS ARM64 the packaged read-only kernel helper checks the same-user process, executable,
38
+ private root and digest of its unique context token without entering the JVM. It emits no token,
39
+ argument list or environment values. PID birth and profile bindings remain independently checked.
40
+ A missing/unreadable/mismatched proof stays blocked; never kill by port, basename or guessed PID.
41
+ Other platforms retain their existing ownership authentication; this does not claim equivalent
42
+ hang recovery where no independent proof is available. No compiler is required on installation.
43
+
27
44
  ## Interrupted save or close
28
45
 
29
46
  After restoring profile storage, inspect the selected session and target with
@@ -84,6 +84,17 @@ those links or their profile/source ancestors manually after migrating.
84
84
 
85
85
  ## Interruptions, uncertainty and retention
86
86
 
87
+ If a completed migration no longer agrees with the Factory profile setting, ordinary
88
+ commands and `storage resume` fail. After inspecting `storage show`, explicitly restore
89
+ the setting with `storage resume --repair-profile --json`. This Update operation verifies
90
+ the journal, source/destination identities, destination ownership and every compatibility
91
+ link before changing only `analysisDirectory`. Later destination research is preserved;
92
+ the old migration manifest is not used to roll it back. A different selected location,
93
+ missing volume or replaced compatibility path blocks repair. Preserve conflicting paths
94
+ and restore the recorded links before retrying; repair never deletes or merges them.
95
+ Process-inventory failures are reported as `storage.inventory` with the original cause
96
+ available under `--verbose`. A terminal unavailable migration is never a successful resume.
97
+
87
98
  ```sh
88
99
  insight-cli storage resume --json
89
100
  insight-cli storage rollback --json
@@ -3,7 +3,7 @@ name: insight-binary
3
3
  description: Query structured PE, ELF and thin Mach-O headers, sections, imports and exports, or make an explicitly guarded new PE subsystem copy through insight-cli binary. Use for scoped binary structure, linkage and copy-only header edits without target execution.
4
4
  ---
5
5
 
6
- # Insight Binary 0.5.4
6
+ # Insight Binary 0.5.5
7
7
 
8
8
  If local help/status reports AppData or IPC access denied, retry that **read-only probe**
9
9
  with the agent host's normal permission escalation, then use that route for later authorized
@@ -3,7 +3,7 @@ name: insight-capture
3
3
  description: Capture one explicitly authorized native program run, exact stdout/stderr and declared file snapshots through insight-cli capture, then inspect or compare retained reports without replay. Use for controlled input/output research, preserving original files and interrupted or unknown outcomes.
4
4
  ---
5
5
 
6
- # Insight Capture 0.5.4
6
+ # Insight Capture 0.5.5
7
7
 
8
8
  If local help/status reports AppData or IPC access denied, retry that **read-only probe**
9
9
  with the agent host's normal permission escalation, then use that route for later authorized
@@ -3,7 +3,7 @@ name: insight-cli
3
3
  description: Choose and prepare reverse-engineering capabilities from the insight-cli toolbox for binary, managed-code and runtime investigations. Use to discover the appropriate tool or its packaged skill before installing native runtimes.
4
4
  ---
5
5
 
6
- # insight-cli 0.5.4
6
+ # insight-cli 0.5.5
7
7
 
8
8
  If local help/status reports AppData or IPC access denied, retry that **read-only probe**
9
9
  with the agent host's normal permission escalation, then use that route for later authorized