@eyeauras/insight-cli 0.2.1 → 0.2.3

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 (82) hide show
  1. package/.cli-package-build.json +1 -1
  2. package/SKILL.md +1 -1
  3. package/dist/src/budgets.d.ts +2 -0
  4. package/dist/src/budgets.js +13 -0
  5. package/dist/src/cli.js +20 -1
  6. package/dist/src/contexts.js +3 -2
  7. package/dist/src/lifecycle.d.ts +2 -0
  8. package/dist/src/research.d.ts +3 -0
  9. package/dist/src/research.js +36 -6
  10. package/dist/src/server.js +13 -1
  11. package/dist/src/tool-runtime/java-arguments.d.ts +6 -0
  12. package/dist/src/tool-runtime/java-arguments.js +44 -0
  13. package/dist/src/tool-runtime/supervisor.js +3 -0
  14. package/dist/src/transport.d.ts +2 -1
  15. package/dist/src/transport.js +8 -3
  16. package/dist/src/workspace.d.ts +11 -0
  17. package/dist/src/workspace.js +18 -1
  18. package/package.json +1 -1
  19. package/references/managed-runtime.md +5 -0
  20. package/references/native-workflows.md +10 -0
  21. package/references/recovery.md +26 -0
  22. package/references/setup.md +17 -0
  23. package/skill-bundle/insight-artifacts/SKILL.md +1 -1
  24. package/skill-bundle/insight-binary/SKILL.md +1 -1
  25. package/skill-bundle/insight-capture/SKILL.md +1 -1
  26. package/skill-bundle/insight-cli/SKILL.md +1 -1
  27. package/skill-bundle/insight-die/SKILL.md +1 -1
  28. package/skill-bundle/insight-dnspy/SKILL.md +1 -1
  29. package/skill-bundle/insight-dotnet-dump/SKILL.md +1 -1
  30. package/skill-bundle/insight-dotnet-stack/SKILL.md +1 -1
  31. package/skill-bundle/insight-dotnet-trace/SKILL.md +1 -1
  32. package/skill-bundle/insight-frida/SKILL.md +1 -1
  33. package/skill-bundle/insight-ghidra/SKILL.md +3 -1
  34. package/skill-bundle/insight-ghidra/references/managed-runtime.md +5 -0
  35. package/skill-bundle/insight-ghidra/references/native-workflows.md +10 -0
  36. package/skill-bundle/insight-ghidra/references/recovery.md +26 -0
  37. package/skill-bundle/insight-ghidra/references/setup.md +17 -0
  38. package/skill-bundle/insight-ilspy/SKILL.md +1 -1
  39. package/skill-bundle/insight-interop/SKILL.md +1 -1
  40. package/skill-bundle/insight-lldb/SKILL.md +1 -1
  41. package/skill-bundle/insight-llvm/SKILL.md +1 -1
  42. package/skill-bundle/insight-managed-heap/SKILL.md +1 -1
  43. package/skill-bundle/insight-managed-metadata/SKILL.md +1 -1
  44. package/skill-bundle/insight-mitmproxy/SKILL.md +1 -1
  45. package/skill-bundle/insight-overview/SKILL.md +1 -1
  46. package/skill-bundle/insight-procmon/SKILL.md +1 -1
  47. package/skill-bundle/insight-re/SKILL.md +1 -1
  48. package/skill-bundle/insight-re/references/recovery.md +26 -0
  49. package/skill-bundle/insight-sevenzip/SKILL.md +1 -1
  50. package/skill-bundle/insight-strace/SKILL.md +1 -1
  51. package/skill-bundle/insight-tshark/SKILL.md +1 -1
  52. package/skill-bundle/insight-windbg/SKILL.md +1 -1
  53. package/skill-bundle/manifest.json +1 -1
  54. package/skills/insight-artifacts/SKILL.md +1 -1
  55. package/skills/insight-binary/SKILL.md +1 -1
  56. package/skills/insight-capture/SKILL.md +1 -1
  57. package/skills/insight-die/SKILL.md +1 -1
  58. package/skills/insight-dnspy/SKILL.md +1 -1
  59. package/skills/insight-dotnet-dump/SKILL.md +1 -1
  60. package/skills/insight-dotnet-stack/SKILL.md +1 -1
  61. package/skills/insight-dotnet-trace/SKILL.md +1 -1
  62. package/skills/insight-frida/SKILL.md +1 -1
  63. package/skills/insight-ghidra/SKILL.md +3 -1
  64. package/skills/insight-ghidra/references/managed-runtime.md +5 -0
  65. package/skills/insight-ghidra/references/native-workflows.md +10 -0
  66. package/skills/insight-ghidra/references/recovery.md +26 -0
  67. package/skills/insight-ghidra/references/setup.md +17 -0
  68. package/skills/insight-ilspy/SKILL.md +1 -1
  69. package/skills/insight-interop/SKILL.md +1 -1
  70. package/skills/insight-lldb/SKILL.md +1 -1
  71. package/skills/insight-llvm/SKILL.md +1 -1
  72. package/skills/insight-managed-heap/SKILL.md +1 -1
  73. package/skills/insight-managed-metadata/SKILL.md +1 -1
  74. package/skills/insight-mitmproxy/SKILL.md +1 -1
  75. package/skills/insight-overview/SKILL.md +1 -1
  76. package/skills/insight-procmon/SKILL.md +1 -1
  77. package/skills/insight-re/SKILL.md +1 -1
  78. package/skills/insight-re/references/recovery.md +26 -0
  79. package/skills/insight-sevenzip/SKILL.md +1 -1
  80. package/skills/insight-strace/SKILL.md +1 -1
  81. package/skills/insight-tshark/SKILL.md +1 -1
  82. package/skills/insight-windbg/SKILL.md +1 -1
@@ -1 +1 @@
1
- {"version":1,"artifact":"b65915991d87f45608bceba8cc7afa48b2e04f292149acf604aaf91ec2d2d6ba"}
1
+ {"version":1,"artifact":"94653e997ccdf18c2e7cb73ac89fc02e746c119ae6971b374cfcafbda087b493"}
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.2.1
6
+ # insight-cli 0.2.3
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
@@ -0,0 +1,2 @@
1
+ /** Execution policy, deliberately separate from immutable program load settings. */
2
+ export declare function ghidraBudget(values: Record<string, unknown>, phase: 'import' | 'save'): number;
@@ -0,0 +1,13 @@
1
+ import { fail } from './model.js';
2
+ /** Execution policy, deliberately separate from immutable program load settings. */
3
+ export function ghidraBudget(values, phase) {
4
+ const field = phase === 'import' ? 'initialImportSeconds' : 'saveSeconds';
5
+ const fallback = phase === 'import' ? 3600 : 120;
6
+ const value = values[field] === undefined ? fallback : values[field];
7
+ if (typeof value !== 'number' && (typeof value !== 'string' || !/^\d+$/.test(value)))
8
+ fail('configuration', field + ' must be an integer number of seconds.');
9
+ const seconds = Number(value), max = phase === 'import' ? 86400 : 3600;
10
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > max)
11
+ fail('configuration', field + ' must be between 1 and ' + max + ' seconds.');
12
+ return seconds * 1000;
13
+ }
package/dist/src/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ghidraBudget } from './budgets.js';
1
2
  import { captureCommands } from './capture.js';
2
3
  import { binaryCommands } from './binary.js';
3
4
  import { skillCommand, toolboxCommands } from './toolbox.js';
@@ -194,6 +195,8 @@ const fields = [
194
195
  ['repository', '--repository <name>', 'Repository on the external server'],
195
196
  ['readerUser', '--reader-user <name>', 'Read-only external account'],
196
197
  ['publisherUser', '--publisher-user <name>', 'External publication account'],
198
+ ['initialImportSeconds', '--initial-import-seconds <seconds>', 'Initial import/analysis budget (default 3600, range 1–86400 seconds)'],
199
+ ['saveSeconds', '--save-seconds <seconds>', 'Native save/export budget (default 120, range 1–3600 seconds)'],
197
200
  ['maxContexts', '--max-contexts <count>', 'Concurrent contexts (default 4, maximum 16)'],
198
201
  ['debuggerPython', '--debugger-python <path>', 'Optional Python executable for a configured debugger offer'],
199
202
  ['windbgDir', '--windbg-dir <path>', 'Optional Windows debugger runtime directory'],
@@ -260,7 +263,7 @@ export function createDefinition(options = {}) {
260
263
  name: 'insight-cli', applicationId: 'insight-cli', version: packageVersion, description: 'Isolated research contexts and coordinated Ghidra analysis',
261
264
  resources: [owner], permissions: { categories: [{ name: 'Destructive', description: 'Explicit deletion or replacement of retained research data', enabledByDefault: false }] }, ...(options.runtime ? { runtime: options.runtime } : {}),
262
265
  profile: { fields: fields.map(([name, flags, description]) => ({ name: name, flags: flags, description: description,
263
- ...(name === 'maxContexts' ? { normalize: (value) => number(value, 16, 1) } : {}) })) },
266
+ ...(['maxContexts', 'initialImportSeconds', 'saveSeconds'].includes(name) ? { normalize: (value) => number(value, name === 'maxContexts' ? 16 : name === 'saveSeconds' ? 3600 : 86400, 1) } : {}) })) },
264
267
  auth: {
265
268
  loginOptions: [{ flags: '--role <role>', description: 'External reader, publisher or archive role', defaultValue: 'reader' },
266
269
  { flags: '--password-stdin', description: 'Read the password from stdin' }],
@@ -351,6 +354,22 @@ export function createDefinition(options = {}) {
351
354
  }, { permission: Permission.ReadOnly, options: [{ flags: '--target <id>', description: 'Inspect one target context' }, ...paged] }),
352
355
  ]),
353
356
  command('ghidra', 'Research through an isolated saved context', [
357
+ command('budgets', 'Inspect or configure native execution budgets', [
358
+ command('show', 'Read effective per-request native budgets', (_invocation, c) => ({ profile: c.profile.name, initialImportSeconds: ghidraBudget(c.profile.values, 'import') / 1000, saveSeconds: ghidraBudget(c.profile.values, 'save') / 1000 }), { permission: Permission.ReadOnly }),
359
+ command('set', 'Configure subsequent native requests without invalidating retained research', async ({ options: o }, c) => {
360
+ if (o.initialImportSeconds === undefined && o.saveSeconds === undefined)
361
+ fail('invalid', 'Supply at least one native budget.');
362
+ const store = options.runtime?.profileStore ?? new ProfileStore({ applicationId: c.appArguments.AppName, appArguments: c.appArguments });
363
+ const patch = {
364
+ ...(o.initialImportSeconds === undefined ? {} : { initialImportSeconds: number(o.initialImportSeconds, 86400, 1) }),
365
+ ...(o.saveSeconds === undefined ? {} : { saveSeconds: number(o.saveSeconds, 3600, 1) })
366
+ };
367
+ const { values } = await store.set(c.profile.name, patch);
368
+ return { profile: c.profile.name, state: 'configured', initialImportSeconds: ghidraBudget(values, 'import') / 1000, saveSeconds: ghidraBudget(values, 'save') / 1000, appliesTo: 'subsequent-requests' };
369
+ }, { permission: Permission.Update, options: [{ flags: '--initial-import-seconds <seconds>', description: 'Initial import/analysis: 1–86400 seconds' },
370
+ { flags: '--save-seconds <seconds>', description: 'Native save/export: 1–3600 seconds' }] })
371
+ ]),
372
+ command('abandon', 'Retain an uncertain program attempt and resume its previous saved view', ({ options: o }, c) => owner.selected(c).research.abandon(scope(o), id(o.requestId), Boolean(o.acknowledgeUnknownEffects)), { permission: 'Destructive', options: [...scoped, { flags: '--request-id <id>', description: 'Original unresolved request ID from sessions status', required: true }, { flags: '--acknowledge-unknown-effects', description: 'Acknowledge that the abandoned effect remains unknown; no script is replayed' }] }),
354
373
  command('refresh', 'Receive safe shared changes while retaining local conflicts', ({ options: o }, c) => owner.selected(c).research.refresh(scope(o), o.requestId === undefined ? undefined : id(o.requestId)), { permission: Permission.Update, options: [...scoped, request] }),
355
374
  command('merge', 'Publish the complete selected context or retain every conflict', ({ options: o }, c) => owner.selected(c).research.merge(scope(o), o.requestId === undefined ? undefined : id(o.requestId)), { permission: Permission.Update, options: [...scoped, request] }),
356
375
  command('open [input]', 'Start independent work or continue a context', async ({ args, options: o }, c) => {
@@ -1,3 +1,4 @@
1
+ import { ghidraBudget } from './budgets.js';
1
2
  import { launchProcess, terminateProcess } from './tool-runtime/supervisor.js';
2
3
  import { runTool } from './tool-runtime/run.js';
3
4
  import { verifyGhidraProcess } from './ghidra-processes.js';
@@ -139,7 +140,7 @@ export class GhidraContexts {
139
140
  await log.close();
140
141
  if (!child.pid)
141
142
  fail('launch', 'Owned Ghidra context could not be launched.');
142
- const record = { binding, http: new HttpBridge(binding.endpoint, this.c.fetch, this.c.signal, token), child };
143
+ const record = { binding, http: new HttpBridge(binding.endpoint, this.c.fetch, this.c.signal, token, undefined, ghidraBudget(this.c.profile.values, 'save')), child };
143
144
  this.running.set(root, record);
144
145
  const deadline = Date.now() + 90000;
145
146
  try {
@@ -172,7 +173,7 @@ export class GhidraContexts {
172
173
  if (stable(JSON.parse(await readFile(join(root, 'process.json'), 'utf8'))) !== stable(binding))
173
174
  fail('binding', 'Native context receipt changed.');
174
175
  const token = await this.c.secrets.require(binding.tokenSecret);
175
- const http = new HttpBridge(binding.endpoint, this.c.fetch, this.c.signal, token);
176
+ const http = new HttpBridge(binding.endpoint, this.c.fetch, this.c.signal, token, undefined, ghidraBudget(this.c.profile.values, 'save'));
176
177
  const info = await http.instance();
177
178
  if (info.pid !== binding.pid || info.tcp_port !== Number(new URL(binding.endpoint).port) ||
178
179
  !String(info.project_path ?? '').startsWith(root + sep))
@@ -37,6 +37,8 @@ export declare class Lifecycle {
37
37
  operation: string;
38
38
  state: import("./workspace.js").RequestState;
39
39
  requestId?: string | undefined;
40
+ detail?: {} | undefined;
41
+ id: string;
40
42
  }[];
41
43
  };
42
44
  session: string;
@@ -92,11 +92,14 @@ export declare class Research {
92
92
  operation: string;
93
93
  state: import("./workspace.js").RequestState;
94
94
  requestId?: string | undefined;
95
+ detail?: {} | undefined;
96
+ id: string;
95
97
  }[];
96
98
  };
97
99
  session: string;
98
100
  target: string;
99
101
  };
102
+ abandon(selected: Scope, requestId: string, acknowledged: boolean): Promise<unknown>;
100
103
  open(target: string, session: string | 'new', settings: GhidraLoadSettings, callerId?: string, mode?: ToolMode): Promise<{
101
104
  [k: string]: unknown;
102
105
  } | {
@@ -100,7 +100,29 @@ export class Research {
100
100
  mergeState: model.state === 'unknown' ? 'unknown' : 'idle',
101
101
  conflicts: context.scalars.filter(change => change.conflict).map(change => ({ address: publicAddress(change.address), field: change.field,
102
102
  original: change.original, local: change.local, shared: change.conflict.shared })),
103
- unresolvedRequests: unresolved.map(request => ({ ...(request.callerId ? { requestId: request.callerId } : {}), operation: request.operation, state: request.state })) } };
103
+ unresolvedRequests: unresolved.map(request => ({ id: request.id, ...(request.result ? { detail: request.result } : {}), ...(request.callerId ? { requestId: request.callerId } : {}), operation: request.operation, state: request.state })) } };
104
+ }
105
+ async abandon(selected, requestId, acknowledged) {
106
+ return this.ordered(selected, async () => {
107
+ if (!acknowledged)
108
+ fail('invalid', 'Explicit --acknowledge-unknown-effects is required.');
109
+ const context = this.workspace.context(selected), requests = this.workspace.unresolved(selected);
110
+ const request = this.workspace.scopedRequest(selected, requestId);
111
+ if (request?.state === 'failed' && object(request.result ?? {}).state === 'abandoned')
112
+ return request.result;
113
+ if (!request || !['running', 'unknown'].includes(request.state) || requests.length !== 1 || object(request.execution ?? {}).kind !== 'program')
114
+ fail('blocked', 'Abandon requires exactly one unresolved private program operation.');
115
+ if (this.workspace.activePublication(selected.target) || context.debugger?.connected)
116
+ fail('blocked', 'Publication or a live debugger prevents private abandonment.');
117
+ const execution = object(request.execution), snapshot = context.workingSnapshot;
118
+ if (!snapshot || execution.generation !== context.generation || object(execution.workingSnapshot).id !== snapshot.id)
119
+ fail('binding', 'The previous saved program view no longer matches the attempt.');
120
+ await this.workspace.artifacts.verify(snapshot);
121
+ const backend = await this.backend();
122
+ if (!backend.closed || !await backend.closed(selected))
123
+ fail('busy', 'The original native worker must be stopped before abandoning its uncertain program attempt.');
124
+ return this.workspace.abandonProgramRequest(selected, request.id, context.generation);
125
+ });
104
126
  }
105
127
  async open(target, session, settings, callerId, mode) {
106
128
  // Mode belongs to the request's effective intent. It does not alter target load identity.
@@ -777,6 +799,7 @@ export class Research {
777
799
  const context = this.workspace.context(selected);
778
800
  this.workspace.startRequest(request.id, { kind: 'program', operation, generation: context.generation, workingSnapshot: context.workingSnapshot,
779
801
  savedVerification: !!verifySaved });
802
+ let savedSnapshot, phase = 'operation';
780
803
  try {
781
804
  const raw = await backend.call(selected, operation, parameters), response = raw && typeof raw === 'object' && !Array.isArray(raw) ? object(raw) : { output: raw };
782
805
  if (['rejected', 'partial', 'failed', 'error'].includes(String(response.status)) || response.success === false)
@@ -784,7 +807,10 @@ export class Research {
784
807
  if (!verifySaved && response.success !== true && response.status !== 'success')
785
808
  fail('partial', 'Program operation did not acknowledge its effect.');
786
809
  let value = await verify(backend, response);
810
+ phase = 'save';
787
811
  const snapshot = await backend.saved(selected);
812
+ savedSnapshot = snapshot;
813
+ phase = 'verification';
788
814
  if (verifySaved)
789
815
  value = await verifySaved(backend, snapshot, response);
790
816
  const scalars = await this.savedScalars(context, backend, snapshot), complexChanges = [...context.complexChanges, request.id], result = { ...selected, state: 'applied', sharedState: 'pending',
@@ -797,12 +823,16 @@ export class Research {
797
823
  this.workspace.recordRequest(request.id, 'failed', { ...selected, state: 'rejected', reason: 'native_transaction_rolled_back' });
798
824
  throw error;
799
825
  }
800
- let savedSnapshot;
801
- try {
802
- savedSnapshot = await backend.saved(selected);
826
+ // Retain supported structural candidates after a lost operation reply. Never
827
+ // dispatch another save after a save deadline or an arbitrary script failure.
828
+ if (phase === 'operation' && verifySaved && !['/run_script_inline', '/run_ghidra_script'].includes(operation)) {
829
+ try {
830
+ savedSnapshot = await backend.saved(selected);
831
+ }
832
+ catch { /* Native evidence stays retained. */ }
803
833
  }
804
- catch { /* A failed/dead backend retains its native stage and the last acknowledged snapshot. */ }
805
- this.workspace.recordRequest(request.id, 'unknown', { ...selected, state: 'unknown', ...(savedSnapshot ? { savedSnapshot } : {}) });
834
+ const detail = error instanceof CliError ? { code: error.code, ...(error.result ? { result: error.result } : {}) } : {};
835
+ this.workspace.recordRequest(request.id, 'unknown', { ...selected, state: 'unknown', phase, ...detail, ...(savedSnapshot ? { savedSnapshot } : {}) });
806
836
  throw error;
807
837
  }
808
838
  });
@@ -1,3 +1,5 @@
1
+ import { ghidraBudget } from './budgets.js';
2
+ import { CliError } from '@eyeauras/cli-factory';
1
3
  import { launchProcess, terminateProcess } from './tool-runtime/supervisor.js';
2
4
  import { runTool } from './tool-runtime/run.js';
3
5
  import { randomUUID, randomBytes, createHash } from 'node:crypto';
@@ -199,7 +201,17 @@ export class GhidraServer {
199
201
  async programStatus(binding, path) {
200
202
  return this.run('program-status', 'InsightRepositoryProbe', [binding.repository, path, 'reader'], await this.credentials(binding, 'reader'));
201
203
  }
202
- async initialImport(root) { return this.run('initial-import', 'InsightInitialImport', [root], {}, undefined, [0], 600000); }
204
+ async initialImport(root) {
205
+ const timeoutMilliseconds = ghidraBudget(this.c.profile.values, 'import');
206
+ try {
207
+ return await this.run('initial-import', 'InsightInitialImport', [root], {}, undefined, [0], timeoutMilliseconds);
208
+ }
209
+ catch (error) {
210
+ if (error instanceof CliError)
211
+ throw new CliError(error.message + ' Initial import remains unproved. Increase profile initialImportSeconds and continue the original scope; incomplete private analysis restarts from the retained input.', { code: error.code, result: { phase: 'initial-import', timeoutMilliseconds, budgetSetting: 'initialImportSeconds', outcome: 'unknown', diagnostics: error.result } });
212
+ throw error;
213
+ }
214
+ }
203
215
  async initialPublication(binding, root, operation = 'stage') {
204
216
  return this.run('initial-publication', 'db.InsightInitialPublication', [root, operation], await this.credentials(binding, operation.startsWith('verify') ? 'reader' : 'publisher'));
205
217
  }
@@ -0,0 +1,6 @@
1
+ /** The Windows java launcher uses ACP, while Java 21 javac reads UTF-8 @files. */
2
+ export declare function encodeJavaLauncherFile(value: string): Promise<Buffer>;
3
+ /** Java's quoted argument-file grammar (not JSON: it has no \u escapes). */
4
+ export declare function quoteJavaArgument(value: string): string;
5
+ /** Keep all other argv entries in place, including JVM flags and application arguments. */
6
+ export declare function javaClasspathArguments(root: string, tool: string, executable: string, args: string[], platform?: string): Promise<string[]>;
@@ -0,0 +1,44 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { join, win32 } from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ const exec = promisify(execFile);
6
+ /** The Windows java launcher uses ACP, while Java 21 javac reads UTF-8 @files. */
7
+ export async function encodeJavaLauncherFile(value) {
8
+ if (!/[^\x00-\x7f]/.test(value))
9
+ return Buffer.from(value, 'ascii');
10
+ // Fixed script; text travels over stdin, never through PowerShell's command parser.
11
+ // Match the native launcher without silently replacing unmappable path characters.
12
+ const script = '[Console]::InputEncoding=[Text.UTF8Encoding]::new($false,$true);$s=[Console]::In.ReadToEnd();$e=[Text.Encoding]::GetEncoding([Text.Encoding]::Default.CodePage,[Text.EncoderExceptionFallback]::new(),[Text.DecoderExceptionFallback]::new());[Console]::Out.Write([Convert]::ToBase64String($e.GetBytes($s)))';
13
+ const pending = exec(join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 15000, maxBuffer: Math.max(4096, Buffer.byteLength(value) * 4) });
14
+ pending.child.stdin?.on('error', () => { });
15
+ pending.child.stdin?.end(value, 'utf8');
16
+ try {
17
+ return Buffer.from((await pending).stdout.trim(), 'base64');
18
+ }
19
+ catch {
20
+ throw new Error('Java classpath cannot be encoded for the Windows launcher; no process was started. Inspect the retained run and the JDK system-code-page path restrictions.');
21
+ }
22
+ }
23
+ /** Java's quoted argument-file grammar (not JSON: it has no \u escapes). */
24
+ export function quoteJavaArgument(value) {
25
+ if (value.includes('\0'))
26
+ throw new Error('Java argument contains NUL');
27
+ return '"' + value.replace(/[\\"\n\r\t\f]/g, c => ({ '\\': '\\\\', '"': '\\"', '\n': '\\n', '\r': '\\r', '\t': '\\t', '\f': '\\f' }[c])) + '"';
28
+ }
29
+ /** Keep all other argv entries in place, including JVM flags and application arguments. */
30
+ export async function javaClasspathArguments(root, tool, executable, args, platform = process.platform) {
31
+ if (platform !== 'win32' || tool !== 'ghidra' || !/^(java|javac)\.exe$/i.test(win32.basename(executable)))
32
+ return args;
33
+ // Conservative upper bound for Windows quoting, with room below CreateProcess's 32767 limit.
34
+ if (executable.length * 2 + args.reduce((n, arg) => n + arg.length * 2 + 3, 3) <= 28000)
35
+ return args;
36
+ const index = args.findIndex(arg => arg === '-cp' || arg === '-classpath' || arg === '--class-path');
37
+ if (index < 0 || args[index + 1] === undefined)
38
+ return args;
39
+ const path = join(root, 'java-classpath.args');
40
+ const content = '-classpath\n' + quoteJavaArgument(args[index + 1]) + '\n';
41
+ const bytes = /^java\.exe$/i.test(win32.basename(executable)) && process.platform === 'win32' ? await encodeJavaLauncherFile(content) : Buffer.from(content, 'utf8');
42
+ await writeFile(path, bytes, { flag: 'wx', mode: 0o600 });
43
+ return [...args.slice(0, index), '@' + path, ...args.slice(index + 2)];
44
+ }
@@ -8,6 +8,7 @@ import { setTimeout as delay } from 'node:timers/promises';
8
8
  import { CliError } from '@eyeauras/cli-factory';
9
9
  import { atomicWrite, readJson, toolRoot, withToolLock } from './store.js';
10
10
  import { processIdentity } from './identity.js';
11
+ import { javaClasspathArguments } from './java-arguments.js';
11
12
  import { digest } from '../model.js';
12
13
  const owned = new Map();
13
14
  const ownedKey = (directory, id) => resolve(directory) + '\0' + id;
@@ -70,6 +71,8 @@ export async function launchProcess(directory, spec, executable, args, options =
70
71
  stdio.push('pipe');
71
72
  let child;
72
73
  try {
74
+ args = await javaClasspathArguments(root, spec.tool, executable, args);
75
+ control.signal?.throwIfAborted();
73
76
  child = spawn(gated ? process.execPath : executable, gated ? ['--no-warnings', fileURLToPath(new URL('./launch-worker.js', import.meta.url)), directory, id, String(fd)] : args, { cwd: root, ...options, stdio, windowsHide: true, env: gated ? { PATH: environment.PATH, HOME: root, TMPDIR: root } : environment });
74
77
  }
75
78
  catch (error) {
@@ -13,8 +13,9 @@ export declare class HttpBridge {
13
13
  private signal?;
14
14
  private token?;
15
15
  private timeoutMilliseconds?;
16
+ private saveTimeoutMilliseconds;
16
17
  private schema?;
17
- constructor(endpoint: string, fetch?: typeof globalThis.fetch, signal?: AbortSignal | undefined, token?: string | undefined, timeoutMilliseconds?: number | undefined);
18
+ constructor(endpoint: string, fetch?: typeof globalThis.fetch, signal?: AbortSignal | undefined, token?: string | undefined, timeoutMilliseconds?: number | undefined, saveTimeoutMilliseconds?: number);
18
19
  private request;
19
20
  instance(): Promise<Record<string, unknown>>;
20
21
  descriptor(path: string): Promise<Descriptor>;
@@ -8,16 +8,20 @@ export class HttpBridge {
8
8
  signal;
9
9
  token;
10
10
  timeoutMilliseconds;
11
+ saveTimeoutMilliseconds;
11
12
  schema;
12
- constructor(endpoint, fetch = globalThis.fetch, signal, token, timeoutMilliseconds) {
13
+ constructor(endpoint, fetch = globalThis.fetch, signal, token, timeoutMilliseconds, saveTimeoutMilliseconds = 120000) {
13
14
  this.endpoint = endpoint;
14
15
  this.fetch = fetch;
15
16
  this.signal = signal;
16
17
  this.token = token;
17
18
  this.timeoutMilliseconds = timeoutMilliseconds;
19
+ this.saveTimeoutMilliseconds = saveTimeoutMilliseconds;
18
20
  const url = new URL(endpoint);
19
21
  if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.pathname !== '/')
20
22
  fail('binding', 'Worker bridge must use an explicit loopback HTTP port.');
23
+ if (!Number.isSafeInteger(saveTimeoutMilliseconds) || saveTimeoutMilliseconds < 1000 || saveTimeoutMilliseconds > 3600000)
24
+ fail('invalid', 'Save timeout is outside the supported bound.');
21
25
  if (timeoutMilliseconds !== undefined && (!Number.isSafeInteger(timeoutMilliseconds) || timeoutMilliseconds < 1 || timeoutMilliseconds > 1800000))
22
26
  fail('invalid', 'Bridge timeout is outside the supported bound.');
23
27
  }
@@ -25,13 +29,14 @@ export class HttpBridge {
25
29
  const url = new URL(path, this.endpoint);
26
30
  // Import includes synchronous auto-analysis. Keep discovery/ordinary requests short,
27
31
  // while allowing a real executable to finish its first analysis without losing the reply.
28
- const timeoutMilliseconds = this.timeoutMilliseconds ?? (path === '/import_file' ? 600000 : 30000);
32
+ const saving = path === '/save_program' || path === '/export_program';
33
+ const timeoutMilliseconds = this.timeoutMilliseconds ?? (saving ? this.saveTimeoutMilliseconds : path === '/import_file' ? 600000 : 30000);
29
34
  const signal = this.signal ? AbortSignal.any([this.signal, AbortSignal.timeout(timeoutMilliseconds)]) : AbortSignal.timeout(timeoutMilliseconds);
30
35
  const transportFailure = () => {
31
36
  if (this.signal?.aborted)
32
37
  fail('cancelled', `Ghidra request ${path} was cancelled; its outcome may require reconciliation.`, { operation: path });
33
38
  if (signal.aborted)
34
- fail('timeout', `Ghidra request ${path} exceeded ${timeoutMilliseconds / 1000} seconds; its outcome may require reconciliation.`, { operation: path, timeoutMilliseconds });
39
+ fail('timeout', `Ghidra request ${path} exceeded ${timeoutMilliseconds / 1000} seconds; its outcome may require reconciliation.${saving ? ' Configure profile saveSeconds before future work; inspect sessions status and never replay an uncertain script.' : ''}`, { operation: path, timeoutMilliseconds, ...(saving ? { phase: 'save', budgetSetting: 'saveSeconds', outcome: 'unknown' } : {}) });
35
40
  fail('transport', `Ghidra request ${path} did not complete; its outcome may require reconciliation.`, { operation: path });
36
41
  };
37
42
  for (const [name, value] of Object.entries(query))
@@ -147,6 +147,7 @@ export declare class Workspace {
147
147
  request: ResearchRequest;
148
148
  replay: boolean;
149
149
  };
150
+ scopedRequest(scope: Scope, identity: string): ResearchRequest | undefined;
150
151
  request(request: string): ResearchRequest;
151
152
  reserveAdministration(operation: string, parameters: unknown, callerId?: string): {
152
153
  request: AdministrationRequest;
@@ -190,6 +191,16 @@ export declare class Workspace {
190
191
  rejectPublication(publication: string, result: unknown): Publication;
191
192
  publishedSince(target: string, revision: number): Publication[];
192
193
  saveContext(scope: Scope, expectedGeneration: number, patch: ContextPatch): GhidraContext;
194
+ /** Terminal abandonment retains the previous outcome and saved view atomically. */
195
+ abandonProgramRequest(scope: Scope, request: string, generation: number): {
196
+ state: string;
197
+ outcome: string;
198
+ requestId: string;
199
+ previousResult: {} | null;
200
+ retainedSnapshot: Artifact;
201
+ session: string;
202
+ target: string;
203
+ };
193
204
  /** Snapshot and successful request acknowledgment enter the same durable transaction. */
194
205
  completeSavedRequest(scope: Scope, request: string, expectedGeneration: number, patch: ContextPatch, result: unknown): {
195
206
  context: GhidraContext;
@@ -3,7 +3,7 @@ import { chmodSync, mkdirSync, realpathSync } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { Artifacts } from './artifacts.js';
6
- import { digest, fail, id, stable, text, version } from './model.js';
6
+ import { digest, fail, id, stable, text, version, object } from './model.js';
7
7
  const now = () => new Date().toISOString();
8
8
  function loadSettings(value) {
9
9
  if (typeof value.autoAnalyze !== 'boolean' || Object.keys(value).some(key => !['autoAnalyze', 'language', 'compilerSpec', 'imageBase', 'loader', 'loaderOptions'].includes(key)))
@@ -260,6 +260,10 @@ export class Workspace {
260
260
  return previous ? { request: previous, replay: true } : { request: this.insertRequest(context.id, operation, intent, callerId, hash), replay: false };
261
261
  });
262
262
  }
263
+ scopedRequest(scope, identity) {
264
+ const row = this.db.prepare('SELECT data FROM requests WHERE context=? AND (id=? OR caller_id=?)').get(this.context(scope).id, id(identity), id(identity));
265
+ return row ? JSON.parse(String(row.data)) : undefined;
266
+ }
263
267
  request(request) {
264
268
  const row = this.db.prepare('SELECT data FROM requests WHERE id=?').get(id(request));
265
269
  if (!row)
@@ -483,6 +487,19 @@ export class Workspace {
483
487
  saveContext(scope, expectedGeneration, patch) {
484
488
  return this.transaction(() => this.writeContext(scope, expectedGeneration, patch));
485
489
  }
490
+ /** Terminal abandonment retains the previous outcome and saved view atomically. */
491
+ abandonProgramRequest(scope, request, generation) {
492
+ return this.transaction(() => {
493
+ const previous = this.request(request), context = this.context(scope), execution = object(previous.execution ?? {});
494
+ if (previous.context !== context.id || !['running', 'unknown'].includes(previous.state) || execution.kind !== 'program' || execution.generation !== generation ||
495
+ this.unresolved(scope).length !== 1 || this.activePublication(scope.target) || !context.workingSnapshot || object(execution.workingSnapshot).id !== context.workingSnapshot.id)
496
+ fail('binding', 'Uncertain attempt changed before abandonment.');
497
+ const result = { ...scope, state: 'abandoned', outcome: 'unknown', requestId: previous.callerId ?? previous.id, previousResult: previous.result ?? null, retainedSnapshot: context.workingSnapshot };
498
+ this.writeContext(scope, generation, { state: 'stopped', viewEpoch: (context.viewEpoch ?? 0) + 1 });
499
+ this.writeRequest(request, 'failed', result);
500
+ return result;
501
+ });
502
+ }
486
503
  /** Snapshot and successful request acknowledgment enter the same durable transaction. */
487
504
  completeSavedRequest(scope, request, expectedGeneration, patch, result) {
488
505
  return this.transaction(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eyeauras/insight-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Agent-facing durable sessions and isolated Ghidra research contexts",
6
6
  "main": "./dist/src/index.js",
@@ -22,6 +22,11 @@ A failed install preserves its stage/diagnostics and the old selection. Retrying
22
22
  completed cached archives are reused. A later install/doctor/uninstall authenticates and stops
23
23
  any leftover disposable probe server before proceeding; an unverified live identity blocks recovery. Interrupted partial downloads are never used as complete files.
24
24
 
25
+ On Windows, long Ghidra Java classpaths use an owned per-execution argument file, retained with
26
+ the process diagnostics. `javac` files use UTF-8; `java` launcher files use the Windows system
27
+ code page. Java 21's launcher still requires paths representable in that code page. Unmappable
28
+ characters fail before launch rather than selecting different files through lossy conversion.
29
+
25
30
  `status` without scope is passive: selection, paths, missing components, installations, retained
26
31
  stages, recorded checks and live process receipts. It works before installation. It does not start
27
32
  Java or create a research workspace. `status --session S --target T` retains the scoped live health
@@ -44,6 +44,16 @@ loader IDs, supported language/compiler specifications and native option definit
44
44
  selects a loader explicitly. Primitive options, native address and HexLong options are converted
45
45
  and validated; unsupported complex option classes fail explicitly. One program belongs to a target.
46
46
 
47
+ For a mixed managed/native or protected PE, first inspect the installed loader options. If the
48
+ task needs its native view and CLI metadata parsing emits repeated warnings or prevents import,
49
+ an explicit PE import may select `--loader <PE-loader-ID-from-discovery>` together with
50
+ `--loader-options '{"Parse CLI headers (if present)":false}'`.
51
+ This is a per-target analysis choice: record it with the findings. For an alternative managed
52
+ import of the same bytes, use a separate disposable profile; targets within one profile reuse
53
+ the same immutable input identity. An output-limit failure means import/analysis is incomplete; inspect
54
+ the retained diagnostics and first-import recovery state before continuing. Do not treat warnings,
55
+ a killed worker or a partial snapshot as a successful analysis or blindly repeat publication.
56
+
47
57
  `formats exporters` lists compatible native exporters and their options. `formats export
48
58
  --exporter 'Raw Bytes' --ranges '[{"start":"0x10001","end":"0x10003"}]'
49
59
  --output-directory <new-directory>` requires a format supporting restricted address sets.
@@ -51,3 +51,29 @@ goto. If its original owned GUI process has exited, continuation reports the unc
51
51
  as disconnected and retains saved program work. It does not recreate the old cursor or re-enable
52
52
  a previous prompt policy. Other unproved live UI effects remain retained until their outcome is
53
53
  established or the original process is known to have ended.
54
+
55
+ ## Abandon an uncertain private program attempt
56
+
57
+ A post-mutation save timeout does not repeat the script or issue another save in the
58
+ error handler. Status exposes the original request identity and retained phase/budget.
59
+ Arbitrary script effects are never inferred from metadata or from an unverified DB.
60
+ After inspecting the retained attempt, explicitly stop only its verified owned worker
61
+ (`tools processes`, then `tools processes kill <process>` if required). Resume the
62
+ last acknowledged private snapshot with:
63
+
64
+ ```sh
65
+ insight-cli permissions grant Destructive
66
+ insight-cli ghidra abandon --session <session> --target <target> \
67
+ --request-id <original-request> --acknowledge-unknown-effects --json
68
+ insight-cli ghidra open --session <session> --target <target> --json
69
+ ```
70
+
71
+ Abandon requires exactly one unresolved private program operation, an unchanged verified
72
+ previous snapshot/generation, a stopped original worker and no active publication or live
73
+ debugger. It retains the native project, candidate artifacts, original intent/execution and
74
+ previous diagnostic result. Its terminal request is `failed` / `state: abandoned` with
75
+ `outcome: unknown`; no effect is marked applied. Repeating abandonment retrieves the same
76
+ result. Earlier saved private edits remain pending. The original changing request ID cannot
77
+ execute again. Abandoning never undoes external effects or establishes what an arbitrary
78
+ script did; the acknowledgement explicitly accepts that uncertainty. Publication recovery,
79
+ initial import, UI and debugger attempts use their own protocols and cannot use this command.
@@ -48,3 +48,20 @@ debugger runtime directory when relevant. These are runtime profile settings, no
48
48
 
49
49
  Archive exchange is opt-in through `archiveUrl`; its optional token uses Factory secrets.
50
50
  See [archive](archive.md) for the service contract and submission recovery behavior.
51
+
52
+ ## Native operation budgets
53
+
54
+ Profile `initialImportSeconds` defaults to 3600 (1–86400 seconds); `saveSeconds`
55
+ defaults to 120 (1–3600 seconds). Configure them with `ghidra budgets set
56
+ --initial-import-seconds 7200 --save-seconds 300`; `ghidra budgets show` reads effective values.
57
+ This uses Factory ProfileStore without invalidating research or stopping native contexts.
58
+ Active requests retain their original budget. Generic `profile set` remains guarded for
59
+ profiles containing research; initial profile creation also accepts these fields. Both are execution policy, independent
60
+ of immutable target/load identity. Save and packed snapshot export use the save budget;
61
+ discovery and ordinary HTTP queries keep their 30-second budget. Each native request has
62
+ its own budget, not a total command deadline. Cancellation remains independent.
63
+
64
+ After an import timeout, wait for the original worker to end, raise its budget and continue
65
+ the original scope. Incomplete private analysis restarts; a completed receipt is reused.
66
+ A save timeout reports `phase: save`, the effective budget and `saveSeconds`. Inspect
67
+ `sessions status`; a native log or broken response stream cannot prove a saved snapshot.
@@ -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.2.1
6
+ # Insight Artifacts 0.2.3
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-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.2.1
6
+ # Insight Binary 0.2.3
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.2.1
6
+ # Insight Capture 0.2.3
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.2.1
6
+ # insight-cli 0.2.3
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-die
3
3
  description: Classify unknown executable or container inputs by format, architecture and compiler/runtime/packer signatures with Detect It Easy before choosing managed or native analysis. Use insight-cli die for static triage and preserve scan uncertainty.
4
4
  ---
5
5
 
6
- # Insight Detect It Easy 0.2.1
6
+ # Insight Detect It Easy 0.2.3
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-dnspy
3
3
  description: Debug a managed Windows EXE at an IL breakpoint and read primitive arguments, or change one expected IL Int32 constant in a new assembly copy through insight-cli dnspy. Use when C# decompilation needs observed execution or a controlled binary edit.
4
4
  ---
5
5
 
6
- # Insight dnSpyEx 0.2.1
6
+ # Insight dnSpyEx 0.2.3
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-dotnet-dump
3
3
  description: Capture a managed heap dump or inspect compatible .NET dumps with SOS to recover waiting stacks, objects, fields and retention evidence.
4
4
  ---
5
5
 
6
- # Insight .NET Dump 0.2.1
6
+ # Insight .NET Dump 0.2.3
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-dotnet-stack
3
3
  description: Inspect live managed thread stacks to identify active or waiting work in an explicitly selected .NET process without taking a dump.
4
4
  ---
5
5
 
6
- # Insight .NET Stack 0.2.1
6
+ # Insight .NET Stack 0.2.3
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-dotnet-trace
3
3
  description: Collect and inspect bounded .NET EventPipe traces to identify sampled thread work, waits or runtime activity; distinguish wall-clock samples from CPU consumption.
4
4
  ---
5
5
 
6
- # Insight .NET Trace 0.2.1
6
+ # Insight .NET Trace 0.2.3
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