@eyeauras/insight-cli 0.1.0 → 0.2.0

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.
@@ -1 +1 @@
1
- {"version":1,"artifact":"c3714e4dd4db303fdccc352327ce7c7f5dff074726238298a6953be70aca1db3"}
1
+ {"version":1,"artifact":"e6afb01edf053fe9b2087f5cc2ca38dd13cb0587a804ebbb1d39c4e5f433c6a3"}
package/README.md CHANGED
@@ -17,14 +17,17 @@ The executable is `insight-cli`. Its global npm executable directory must be on
17
17
  No source checkout, TypeScript build or separate Factory installation is required.
18
18
  The pinned Factory IPC runtime is a normal npm dependency fetched from an immutable
19
19
  GitHub tarball URL; installation therefore needs npm registry and GitHub access.
20
- Ghidra 12.1.2 and Java 21 are external dependencies for Ghidra commands.
20
+ Use `insight-cli ghidra install` to deliver the pinned Ghidra/JDK/native/server runtime.
21
+ External Ghidra launchers and Java paths are also supported.
21
22
  Help, skill discovery and saving ordinary files do not need Ghidra or Java.
22
23
 
23
24
  ## Start
24
25
 
25
26
  ```sh
26
- insight-cli profile create local --java /path/to/java --ghidra-home /path/to/ghidra
27
+ insight-cli profile create local
27
28
  insight-cli --profile local permissions grant Update
29
+ insight-cli --profile local ghidra install
30
+ insight-cli --profile local ghidra status
28
31
  insight-cli --profile local ghidra open ./client.exe --session new --json
29
32
  ```
30
33
 
@@ -53,3 +56,6 @@ and `insight-cli skill insight-re` to locate packaged instructions and reference
53
56
 
54
57
  Full documentation and verification: [GitHub](https://github.com/iXab3r/insight-cli).
55
58
  License: MIT. The bundled GhidraMCP bridge has its own Apache-2.0 license and notice.
59
+
60
+ Runtime management, local archives and external paths: [managed runtime](references/managed-runtime.md).
61
+ The host debugger and OS permissions are checked separately by `ghidra doctor`.
package/SKILL.md CHANGED
@@ -3,7 +3,10 @@ name: insight-cli
3
3
  description: Research several executable inputs through durable independent sessions and isolated Ghidra contexts using the matching insight-cli facade. Agents supply the investigation and interpret results.
4
4
  ---
5
5
 
6
- # insight-cli 0.1.0
6
+ # insight-cli 0.2.0
7
+
8
+ Use `ghidra status` to inspect runtime readiness; `ghidra install` delivers the pinned runtime.
9
+ For archives, external launchers, server lifecycle or removal, read [managed runtime](references/managed-runtime.md).
7
10
 
8
11
  This skill and its executable helper are one npm package. Run `insight-cli skill` to locate
9
12
  these installed resources. Use generated `--help` for syntax; read the references as needed.
@@ -12,7 +12,7 @@ export function administrationResult(operation, p, raw) {
12
12
  const value = object(raw);
13
13
  if (JSON.stringify(value).length > 1048576)
14
14
  fail('limit', 'Administration result exceeds one MiB.');
15
- if (['status', 'connect', 'disconnect'].includes(operation)) {
15
+ if (['status', 'connect', 'disconnect', 'stop'].includes(operation)) {
16
16
  if (typeof value.connected !== 'boolean' || !['local', 'external'].includes(String(value.mode)))
17
17
  fail('partial', 'Server status is incomplete.');
18
18
  if (value.connected && typeof value.repository !== 'string')
@@ -11,4 +11,4 @@ export declare class Administration {
11
11
  read(operation: string, parameters: Record<string, unknown>): Promise<Record<string, unknown>>;
12
12
  change(operation: string, parameters: Record<string, unknown>, callerId?: string): Promise<unknown>;
13
13
  }
14
- export declare function administrationCommands(service: (context: CommandContext) => Administration): CommandDefinition[];
14
+ export declare function administrationCommands(service: (context: CommandContext) => Administration, unconfiguredStatus?: (context: CommandContext) => Promise<Record<string, unknown> | undefined>): CommandDefinition[];
@@ -1,6 +1,7 @@
1
1
  import { command, Permission } from '@eyeauras/cli-factory';
2
2
  import { fail, id, text } from './model.js';
3
3
  import { option, parsed, key } from './arguments.js';
4
+ import { runtimeProcesses } from './runtime-manager.js';
4
5
  import { administrationResult } from './administration-results.js';
5
6
  const repository = (value) => { const name = text(value, 128); if (name !== name.trim() || /[\\/\0]/.test(name) || name.startsWith('.'))
6
7
  fail('invalid', 'Repository name must be a single bounded name.'); return name; };
@@ -67,6 +68,8 @@ export class Administration {
67
68
  const repo = { name: 'repository', type: 'string', required: true }, path = { name: 'path', type: 'string', required: false, default: '/' };
68
69
  const leaves = [
69
70
  { names: ['status'], operation: 'status', gate: Permission.ReadOnly, params: [] },
71
+ { names: ['start'], operation: 'connect', gate: Permission.Update, params: [] },
72
+ { names: ['stop'], operation: 'stop', gate: Permission.Update, params: [] },
70
73
  { names: ['connect'], operation: 'connect', gate: Permission.Update, params: [] },
71
74
  { names: ['disconnect'], operation: 'disconnect', gate: Permission.Update, params: [] },
72
75
  { names: ['repositories', 'list'], operation: 'repositories', gate: Permission.ReadOnly, params: [] },
@@ -81,11 +84,22 @@ const leaves = [
81
84
  { names: ['admin', 'terminate-checkout'], operation: 'terminate_checkout', gate: 'Destructive', params: [repo, { ...path, required: true, default: undefined }, { name: 'checkout_id', type: 'string', required: false }] },
82
85
  { names: ['admin', 'terminate-all-checkouts'], operation: 'terminate_all_checkouts', gate: 'Destructive', params: [repo, path] },
83
86
  ];
84
- export function administrationCommands(service) {
87
+ export function administrationCommands(service, unconfiguredStatus) {
85
88
  const children = [];
86
89
  for (const leaf of leaves) {
87
90
  const changing = leaf.gate !== Permission.ReadOnly;
88
- const definition = command(leaf.names.at(-1), 'Operate on the configured server and explicit repository', ({ options: o }, c) => {
91
+ const definition = command(leaf.names.at(-1), 'Operate on the configured server and explicit repository', async ({ options: o }, c) => {
92
+ if (leaf.operation === 'stop') {
93
+ if (c.profile.values.server)
94
+ fail('authorization', 'External server lifecycle is owned by its operator. Use server disconnect.');
95
+ if ((await runtimeProcesses(c.appArguments.AppDataDirectory)).some(p => p.kind !== 'server'))
96
+ fail('busy', 'Close active Ghidra contexts before stopping their server.');
97
+ }
98
+ if (leaf.operation === 'status' && unconfiguredStatus) {
99
+ const passive = await unconfiguredStatus(c);
100
+ if (passive)
101
+ return passive;
102
+ }
89
103
  const parameters = {};
90
104
  for (const p of leaf.params) {
91
105
  const value = o[key(p.name)];
package/dist/src/cli.js CHANGED
@@ -27,16 +27,46 @@ import { VersionTracking, trackingCommands } from './version-tracking.js';
27
27
  import { Extensions, extensionCommands, extensionState, extensionInventoryCommands, managedExtensions } from './extensions.js';
28
28
  import { Synchronization, synchronizationCommands } from './sync-policy.js';
29
29
  import { artifactsCommands } from './saved-materials.js';
30
+ import { RuntimeManager, runtimeCommands, runtimeProcesses } from './runtime-manager.js';
31
+ import { runtimeSelection } from './runtime-selection.js';
30
32
  const bindingFields = ['java', 'ghidraHome', 'mcpJar', 'mcpJarSha256', 'server', 'repository', 'readerUser', 'publisherUser', 'maxContexts', 'debuggerPython', 'windbgDir', 'archiveUrl'];
31
33
  class Owner {
32
34
  options;
33
35
  profiles = new Map();
36
+ runtimeChanging = new Set();
37
+ async runtime(c, action, quiescent = true) {
38
+ const directory = c.appArguments.AppDataDirectory;
39
+ if (this.runtimeChanging.has(directory))
40
+ fail('busy', 'Another runtime operation is in progress.');
41
+ const current = this.profiles.get(directory);
42
+ if (quiescent && current?.tasks.size)
43
+ fail('busy', 'Research operations are in progress; finish them before managing the runtime.');
44
+ this.runtimeChanging.add(directory);
45
+ try {
46
+ if (!quiescent)
47
+ return await action();
48
+ const processes = await runtimeProcesses(directory);
49
+ if (processes.length)
50
+ fail('busy', 'Close Ghidra contexts and stop the owned server before changing or checking the runtime.', { processes });
51
+ if (current) {
52
+ await (await current.backend)?.dispose();
53
+ current.workspace.close();
54
+ this.profiles.delete(directory);
55
+ }
56
+ return await action();
57
+ }
58
+ finally {
59
+ this.runtimeChanging.delete(directory);
60
+ }
61
+ }
34
62
  constructor(options) {
35
63
  this.options = options;
36
64
  }
37
65
  selected(c) {
38
66
  const directory = c.appArguments.AppDataDirectory;
39
- const configuration = digest({ profile: Object.fromEntries(bindingFields.map(key => [key, c.profile.values[key]])), extensions: extensionState(join(directory, 'workspace')).modules.filter(m => m.enabled).map(m => [m.name, m.sha256]) });
67
+ if (this.runtimeChanging.has(directory))
68
+ fail('busy', 'Runtime preparation is in progress.');
69
+ const configuration = digest({ runtime: runtimeSelection(directory), profile: Object.fromEntries(bindingFields.map(key => [key, c.profile.values[key]])), extensions: extensionState(join(directory, 'workspace')).modules.filter(m => m.enabled).map(m => [m.name, m.sha256]) });
40
70
  let profile = this.profiles.get(directory);
41
71
  if (!profile) {
42
72
  profile = { workspace: new Workspace(join(directory, 'workspace'), c.profile.name), tasks: new Map(), configuration };
@@ -64,6 +94,8 @@ class Owner {
64
94
  }
65
95
  async invalidateProfile(app) {
66
96
  const directory = app.AppDataDirectory, profile = this.profiles.get(directory);
97
+ if (this.runtimeChanging.has(directory))
98
+ fail('busy', 'Runtime preparation is in progress; finish it before changing or deleting the profile.');
67
99
  const materials = await readdir(join(directory, 'materials')).catch(error => { if (error.code === 'ENOENT')
68
100
  return []; throw error; });
69
101
  if (existsSync(join(directory, 'workspace', 'workspace.sqlite')) || materials.length)
@@ -190,7 +222,7 @@ function scopedFailures(entry, owner, parents = []) {
190
222
  export function createDefinition(options = {}) {
191
223
  const owner = new Owner(options);
192
224
  const definition = {
193
- name: 'insight-cli', applicationId: 'insight-cli', version: '0.1.0', description: 'Isolated research contexts and coordinated Ghidra analysis',
225
+ name: 'insight-cli', applicationId: 'insight-cli', version: '0.2.0', description: 'Isolated research contexts and coordinated Ghidra analysis',
194
226
  resources: [owner], permissions: { categories: [{ name: 'Destructive', description: 'Explicit deletion or replacement of retained research data', enabledByDefault: false }] }, ...(options.runtime ? { runtime: options.runtime } : {}),
195
227
  profile: { fields: fields.map(([name, flags, description]) => ({ name: name, flags: flags, description: description,
196
228
  ...(name === 'maxContexts' ? { normalize: (value) => number(value, 16, 1) } : {}) })) },
@@ -249,7 +281,7 @@ export function createDefinition(options = {}) {
249
281
  const name = args.name === undefined ? 'insight-cli' : text(args.name);
250
282
  if (!['insight-cli', 'insight-re'].includes(name))
251
283
  fail('notFound', 'Unknown skill; choose insight-cli or insight-re.');
252
- return { version: '0.1.0', name, path: fileURLToPath(new URL(name === 'insight-re' ? '../../skills/insight-re/SKILL.md' : '../../SKILL.md', import.meta.url)),
284
+ return { version: '0.2.0', name, path: fileURLToPath(new URL(name === 'insight-re' ? '../../skills/insight-re/SKILL.md' : '../../SKILL.md', import.meta.url)),
253
285
  skills: ['insight-cli', 'insight-re'] };
254
286
  })],
255
287
  commands: [
@@ -381,7 +413,7 @@ export function createDefinition(options = {}) {
381
413
  ],
382
414
  };
383
415
  return { ...definition, commands: definition.commands?.map(entry => entry.name === 'ghidra' ?
384
- scopedFailures({ ...entry, children: extendQueries(extendQueries(extendQueries(entry.children ?? [], queryCommands(c => owner.selected(c).research)), mutationCommands(c => owner.selected(c).research)), [...sdkCommands(c => owner.selected(c).research), ...sdkFileCommands(c => owner.selected(c).sdkFiles), ...trackingCommands(c => owner.selected(c).tracking), ...crossCommands(c => owner.selected(c).research), ...administrationCommands(c => owner.selected(c).administration),
385
- ...extensionCommands(c => new Extensions(join(c.appArguments.AppDataDirectory, 'workspace'))), ...extensionInventoryCommands(c => owner.selected(c).backend), ...pluginCommands(c => owner.selected(c).ui), ...synchronizationCommands(c => owner.selected(c).synchronization), ...utilityCommands(), ...lifecycleCommands(c => owner.selected(c).lifecycle), ...projectCommands(c => owner.selected(c).projects), ...uiCommands(c => owner.selected(c).ui), ...debuggerCommands(c => owner.selected(c).debugger), ...archiveCommands(c => owner.selected(c).archive)]) }, owner) : entry) };
416
+ scopedFailures({ ...entry, children: extendQueries(extendQueries(extendQueries(entry.children ?? [], queryCommands(c => owner.selected(c).research)), mutationCommands(c => owner.selected(c).research)), [...sdkCommands(c => owner.selected(c).research), ...sdkFileCommands(c => owner.selected(c).sdkFiles), ...trackingCommands(c => owner.selected(c).tracking), ...crossCommands(c => owner.selected(c).research), ...administrationCommands(c => owner.selected(c).administration, options.backend ? undefined : async (c) => { const status = await new RuntimeManager(c).status(); return status.state === 'not_ready' ? { ...status.server, connected: false, state: 'runtime_not_ready', missing: status.missing } : undefined; }),
417
+ ...extensionCommands(c => new Extensions(join(c.appArguments.AppDataDirectory, 'workspace'))), ...extensionInventoryCommands(c => owner.selected(c).backend), ...pluginCommands(c => owner.selected(c).ui), ...synchronizationCommands(c => owner.selected(c).synchronization), ...utilityCommands(), ...runtimeCommands((c, action, quiescent) => owner.runtime(c, action, quiescent)), ...lifecycleCommands(c => owner.selected(c).lifecycle, c => new RuntimeManager(c).status()), ...projectCommands(c => owner.selected(c).projects), ...uiCommands(c => owner.selected(c).ui), ...debuggerCommands(c => owner.selected(c).debugger), ...archiveCommands(c => owner.selected(c).archive)]) }, owner) : entry) };
386
418
  }
387
419
  export const createInsightCli = (options = {}) => createCli(createDefinition(options));
@@ -1,8 +1,9 @@
1
+ import { runtimeProcesses } from './runtime-manager.js';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { randomUUID, randomBytes } from 'node:crypto';
3
4
  import { mkdir, open, readFile, readdir, rename, link, realpath, copyFile, chmod, writeFile } from 'node:fs/promises';
4
5
  import { constants } from 'node:fs';
5
- import { join, delimiter, sep, resolve } from 'node:path';
6
+ import { join, delimiter, sep, resolve, dirname } from 'node:path';
6
7
  import { createServer } from 'node:net';
7
8
  import { setTimeout as delay } from 'node:timers/promises';
8
9
  import { nativeHelpers, nativeInputs, nativeJvmDirectories, nativeExtensionArguments } from './native-inputs.js';
@@ -73,7 +74,16 @@ export class GhidraContexts {
73
74
  fork(c) { return new GhidraContexts(this.workspace, this.inputs, c, this.running, this.modelTasks, this.ownedServer.fork(c), this.capacity); }
74
75
  directory() { return join(this.workspace.directory, 'ghidra-native'); }
75
76
  server() { return this.ownedServer; }
76
- async administration(operation, parameters) { return this.server().administration(operation, parameters); }
77
+ async administration(operation, parameters) {
78
+ if (operation === 'stop') {
79
+ if (this.c.profile.values.server)
80
+ fail('authorization', 'External server lifecycle is owned by its operator. Use server disconnect.');
81
+ const processes = await runtimeProcesses(dirname(this.workspace.directory));
82
+ if (processes.some(p => p.kind !== 'server'))
83
+ fail('busy', 'Close active Ghidra contexts before stopping their server.');
84
+ }
85
+ return this.server().administration(operation, parameters);
86
+ }
77
87
  async sdkFile(operation, parameters) { return this.server().sdkFile(operation, parameters); }
78
88
  async prepareRoot(kind, target, context) {
79
89
  const root = join(this.directory(), kind, context ?? target, randomUUID());
@@ -46,4 +46,4 @@ export declare class Lifecycle {
46
46
  discard(selected: Scope, callerId?: string): Promise<unknown>;
47
47
  saveAll(session: string, callerId?: string): Promise<unknown>;
48
48
  }
49
- export declare function lifecycleCommands(service: (context: CommandContext) => Lifecycle): CommandDefinition[];
49
+ export declare function lifecycleCommands(service: (context: CommandContext) => Lifecycle, runtimeStatus?: (context: CommandContext) => Promise<unknown>): CommandDefinition[];
@@ -138,11 +138,17 @@ export class Lifecycle {
138
138
  return result;
139
139
  }
140
140
  }
141
- export function lifecycleCommands(service) {
141
+ export function lifecycleCommands(service, runtimeStatus) {
142
142
  return [command('changes', 'Manage unpublished edits in the selected context', [
143
143
  command('discard', 'Save a retained copy, then restore the opening base without publication', ({ options: o }, c) => service(c).discard(scope(o), o.requestId === undefined ? undefined : id(o.requestId)), { permission: 'Destructive', options: [...selectedOptions, { flags: '--request-id <id>', description: 'Retrieve the same local discard result' }] })
144
144
  ]),
145
145
  command('save-all', 'Save every Ghidra context in the explicit session; outcomes are per context', ({ options: o }, c) => service(c).saveAll(id(o.session), o.requestId === undefined ? undefined : id(o.requestId)), { permission: Permission.Update, options: [{ flags: '--session <id>', description: 'Explicit session whose contexts are saved', required: true }, { flags: '--request-id <id>', description: 'Retrieve the recorded session save outcomes' }] }),
146
- command('status', 'Verify tool health and inspect the selected private context', ({ options: o }, c) => service(c).health(scope(o)), { permission: Permission.ReadOnly, options: selectedOptions }),
146
+ command('status', 'Inspect the installed runtime, or verify a selected context', ({ options: o }, c) => {
147
+ if (o.session === undefined && o.target === undefined && runtimeStatus)
148
+ return runtimeStatus(c);
149
+ if (o.session === undefined || o.target === undefined)
150
+ fail('invalid', 'Context status requires both --session and --target.');
151
+ return service(c).health(scope(o));
152
+ }, { permission: Permission.ReadOnly, options: selectedOptions.map(option => ({ ...option, required: false })) }),
147
153
  ...['save', 'close'].map(name => command(name, name === 'save' ? 'Save private work without publication' : 'Save and release the owned context; reopen retains local work', ({ options: o }, c) => service(c).change(scope(o), name, o.requestId === undefined ? undefined : id(o.requestId)), { permission: Permission.Update, options: [...selectedOptions, { flags: '--request-id <id>', description: 'Retrieve the lifecycle outcome after a lost response' }] }))];
148
154
  }
@@ -6,6 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { fail, stable, text } from './model.js';
8
8
  import { managedExtensions, extensionJvmArguments } from './extensions.js';
9
+ import { runtimeValues, runtimeSelection } from './runtime-selection.js';
9
10
  export const pinnedMcpJar = '7084e2fda16dc779dd05398e48821ff3f9ace4d32757ea143b257b39689e5fbd';
10
11
  export const packagedMcpJar = fileURLToPath(new URL('../../runtime/GhidraMCP-7.0.0.jar', import.meta.url));
11
12
  /** Ghidra has its own cache/settings overrides in addition to Java's home/tmp properties. */
@@ -78,6 +79,10 @@ export async function nativeRuntimeFingerprint(ghidraVersion, files) {
78
79
  return createHash('sha256').update(stable({ schema: 2, ghidraVersion, hashes })).digest('hex');
79
80
  }
80
81
  export async function nativeInputs(values, directory) {
82
+ if (directory)
83
+ values = runtimeValues(dirname(directory), values);
84
+ if (!values.java || !values.ghidraHome)
85
+ fail('configuration', 'Ghidra is not installed. Run insight-cli ghidra install, or supply external runtime paths.');
81
86
  const requested = [text(values.java, 4096), text(values.ghidraHome, 4096), text(values.mcpJar ?? packagedMcpJar, 4096)];
82
87
  if (!requested.every(isAbsolute))
83
88
  fail('configuration', 'Configure absolute Java, Ghidra home and GhidraMCP jar paths.');
@@ -110,12 +115,18 @@ export async function nativeInputs(values, directory) {
110
115
  join(debug, 'Debugger-agent-lldb/pypkg/dist/ghidralldb-12.1-py3-none-any.whl')
111
116
  ];
112
117
  const files = await nativeRuntimeFiles(ghidraHome, java);
118
+ const baseFingerprint = await nativeRuntimeFingerprint(ghidraVersion, [...files, { name: 'bridge/GhidraMCP-7.0.0.jar', path: mcpJar }]);
119
+ if (directory) {
120
+ const selected = runtimeSelection(dirname(directory));
121
+ if (selected && selected.fingerprint !== baseFingerprint)
122
+ fail('binding', 'Selected runtime inputs differ from their installation receipt; reinstall or restore the verified files.');
123
+ }
113
124
  for (const module of modules)
114
125
  for (const file of module.files)
115
126
  files.push({ name: 'extension/' + module.name + '/' + file.name, path: join(module.root, file.name) });
116
127
  // Logical names bind locations without embedding the author's installation path.
117
128
  files.push({ name: 'bridge/GhidraMCP-7.0.0.jar', path: mcpJar });
118
- const fingerprint = await nativeRuntimeFingerprint(ghidraVersion, files);
129
+ const fingerprint = modules.length ? await nativeRuntimeFingerprint(ghidraVersion, files) : baseFingerprint;
119
130
  return { java, ghidraHome, mcpJar, jars, classpath: jars.join(delimiter), ghidraVersion, fingerprint, debuggerWheels, extensionRoots: modules.map(m => m.root), serverClasspath: baseJars.join(delimiter) };
120
131
  }
121
132
  /** Compile packaged helper sources against the selected installation, never an author checkout. */
@@ -0,0 +1,5 @@
1
+ import type { RuntimeArtifact } from './runtime-catalog.js';
2
+ export declare function fileSha256(path: string): Promise<string>;
3
+ export declare function archiveName(name: string): string;
4
+ export declare function extractArchive(file: string, destination: string, format: 'tgz' | 'zip'): Promise<void>;
5
+ export declare function cacheArtifact(artifact: RuntimeArtifact, cache: string, fetcher: typeof fetch, signal: AbortSignal, offline?: boolean): Promise<string>;
@@ -0,0 +1,127 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { mkdir, open, rename, rm, stat } from 'node:fs/promises';
4
+ import { dirname, join, posix } from 'node:path';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import * as tar from 'tar';
7
+ import * as zip from 'yauzl';
8
+ import { fail } from './model.js';
9
+ export async function fileSha256(path) { const hash = createHash('sha256'); for await (const chunk of createReadStream(path))
10
+ hash.update(chunk); return hash.digest('hex'); }
11
+ export function archiveName(name) {
12
+ if (!name || name.includes('\\') || name.includes('\0') || name.startsWith('/') || /^[a-zA-Z]:/.test(name) || name.split('/').includes('..'))
13
+ fail('invalid', 'Runtime archive contains an unsafe path.');
14
+ return name.replace(/^(\.\/)+/, '');
15
+ }
16
+ export async function extractArchive(file, destination, format) {
17
+ await mkdir(destination, { recursive: true, mode: 0o700 });
18
+ let count = 0, bytes = 0;
19
+ const bound = (name, size) => { archiveName(name); if (++count > 100000 || !Number.isSafeInteger(size) || size < 0 || (bytes += size) > 8 * 1024 ** 3)
20
+ fail('limit', 'Runtime archive exceeds extraction bounds.'); };
21
+ if (format === 'tgz') {
22
+ let unsafe;
23
+ await tar.t({ file, strict: true, filter: (path, entry) => {
24
+ try {
25
+ if (!('type' in entry))
26
+ fail('invalid', 'Unsupported runtime archive metadata.');
27
+ bound(path, entry.size);
28
+ if (!['File', 'Directory', 'SymbolicLink', 'Link', 'OldFile', 'ContiguousFile'].includes(entry.type))
29
+ fail('invalid', 'Unsupported runtime archive entry.');
30
+ if (entry.type === 'SymbolicLink' || entry.type === 'Link') {
31
+ const target = entry.linkpath;
32
+ if (typeof target !== 'string')
33
+ fail('invalid', 'Runtime archive link omitted its target.');
34
+ if (target.startsWith('/') || target.includes('\\') || /^[a-zA-Z]:/.test(target))
35
+ fail('invalid', 'Runtime archive link escapes extraction storage.');
36
+ const joined = posix.normalize(entry.type === 'Link' ? target : posix.join(posix.dirname(path), target));
37
+ archiveName(joined);
38
+ }
39
+ return true;
40
+ }
41
+ catch (error) {
42
+ unsafe ??= error;
43
+ return false;
44
+ }
45
+ } });
46
+ if (unsafe)
47
+ throw unsafe;
48
+ await tar.x({ file, cwd: destination, strict: true, preserveOwner: false, noChmod: false });
49
+ return;
50
+ }
51
+ const archive = await new Promise((ok, bad) => zip.open(file, { lazyEntries: true, strictFileNames: true }, (e, z) => e ? bad(e) : ok(z)));
52
+ await new Promise((ok, bad) => {
53
+ const failed = (e) => { archive.close(); bad(e); };
54
+ archive.on('error', failed);
55
+ archive.on('end', ok);
56
+ archive.on('entry', (entry) => {
57
+ void (async () => {
58
+ bound(entry.fileName, entry.uncompressedSize);
59
+ const mode = entry.externalFileAttributes >>> 16, type = mode & 0o170000;
60
+ if (type && type !== 0o100000 && type !== 0o040000)
61
+ fail('invalid', 'Runtime zip may contain only files and directories.');
62
+ const path = join(destination, entry.fileName);
63
+ if (entry.fileName.endsWith('/'))
64
+ await mkdir(path, { recursive: true, mode: 0o700 });
65
+ else {
66
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
67
+ const stream = await new Promise((ok, bad) => archive.openReadStream(entry, (e, s) => e ? bad(e) : ok(s)));
68
+ await pipeline(stream, createWriteStream(path, { flags: 'wx', mode: mode & 0o111 ? 0o755 : 0o644 }));
69
+ }
70
+ archive.readEntry();
71
+ })().catch(failed);
72
+ });
73
+ archive.readEntry();
74
+ });
75
+ }
76
+ export async function cacheArtifact(artifact, cache, fetcher, signal, offline = false) {
77
+ await mkdir(cache, { recursive: true, mode: 0o700 });
78
+ const path = join(cache, artifact.sha256 + '.' + artifact.format);
79
+ try {
80
+ if ((await stat(path)).isFile() && await fileSha256(path) === artifact.sha256)
81
+ return path;
82
+ fail('binding', 'Cached runtime archive failed its SHA256 check; remove the corrupt cache entry before retrying.');
83
+ }
84
+ catch (error) {
85
+ if (error.code !== 'ENOENT')
86
+ throw error;
87
+ }
88
+ if (offline)
89
+ fail('configuration', 'Required runtime archive is not cached; run ghidra download before using --offline.', { artifact: artifact.name, sha256: artifact.sha256 });
90
+ const temporary = path + '.partial-' + randomUUID();
91
+ try {
92
+ let headers = {};
93
+ if (artifact.url.startsWith('https://ghcr.io/v2/homebrew/core/ghidra/')) {
94
+ const response = await fetcher('https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core/ghidra:pull', { signal, redirect: 'error' });
95
+ if (!response.ok)
96
+ fail('download', 'Anonymous runtime registry authorization failed.');
97
+ const value = await response.json();
98
+ if (!value.token || value.token.length > 8192)
99
+ fail('download', 'Invalid anonymous runtime registry token.');
100
+ headers = { Authorization: 'Bearer ' + value.token };
101
+ }
102
+ const response = await fetcher(artifact.url, { signal, headers });
103
+ if (!response.ok || !response.body)
104
+ fail('download', 'Pinned runtime download failed.', { artifact: artifact.name, status: response.status });
105
+ let size = 0;
106
+ const hash = createHash('sha256'), handle = await open(temporary, 'wx', 0o600);
107
+ try {
108
+ for await (const chunk of response.body) {
109
+ if ((size += chunk.byteLength) > 2 * 1024 ** 3)
110
+ fail('limit', 'Runtime download exceeds two GiB.');
111
+ hash.update(chunk);
112
+ await handle.writeFile(chunk);
113
+ }
114
+ await handle.sync();
115
+ }
116
+ finally {
117
+ await handle.close();
118
+ }
119
+ if (hash.digest('hex') !== artifact.sha256)
120
+ fail('binding', 'Downloaded runtime archive failed its pinned SHA256 check.');
121
+ await rename(temporary, path);
122
+ return path;
123
+ }
124
+ finally {
125
+ await rm(temporary, { force: true });
126
+ }
127
+ }
@@ -0,0 +1,18 @@
1
+ export interface RuntimeArtifact {
2
+ name: string;
3
+ url: string;
4
+ sha256: string;
5
+ format: 'zip' | 'tgz';
6
+ root: string;
7
+ license: string;
8
+ }
9
+ export declare const ghidraArchive: RuntimeArtifact;
10
+ export declare function runtimeCatalog(platform?: string, arch?: string, kernel?: string): {
11
+ schema: number;
12
+ ghidraVersion: string;
13
+ javaVersion: string;
14
+ platform: string;
15
+ architecture: string;
16
+ ghidra: RuntimeArtifact;
17
+ java: RuntimeArtifact;
18
+ };
@@ -0,0 +1,27 @@
1
+ import { release } from 'node:os';
2
+ import { fail } from './model.js';
3
+ const github = 'https://github.com/';
4
+ export const ghidraArchive = { name: 'ghidra-12.1.2', url: github + 'NationalSecurityAgency/ghidra/releases/download/Ghidra_12.1.2_build/ghidra_12.1.2_PUBLIC_20260605.zip', sha256: 'b62e81a0390618466c019c60d8c2f796ced2509c4c1aea4a37644a77272cf99d', format: 'zip', root: 'ghidra_12.1.2_PUBLIC', license: 'Apache-2.0 and included third-party licenses' };
5
+ const bottles = { arm64_sonoma: '40f68357d6b62a08429c36228907c995e3449283b6d2e08ba5a4e30b53a09725', arm64_sequoia: '90aa942184fca2337ad0a123f818f37372ea642e8e69e969d45cc144a6f26fdf', arm64_tahoe: '079007d8a3a477c8d5901e6596947612d86c8641956424a2bcb71ecd798bedcc', sonoma: 'e30f8c84b64331f87f6ef1890cbdded6672bb7fcc8b674794ac93e4b3e62ad2b' };
6
+ const jdks = {
7
+ 'darwin-arm64': ['aarch64_mac', '6ebcf221c9b41507b14c098e93c6ead6440b8d9bd154f8ec666c4c73abbdb201'],
8
+ 'darwin-x64': ['x64_mac', '34180eb03e6d207c388cce3da668f6cc7cd7508c185c24782fadac2c9c0e66f9'],
9
+ 'linux-x64': ['x64_linux', '4b2220e232a97997b436ca6ab15cbf70171ecff52958a46159dfa5a8c44ca4de'],
10
+ 'win32-x64': ['x64_windows', 'd3625e7cadf23787ea540229544b6e2ab494b3b54da1801879e583e1dfee0a64'],
11
+ };
12
+ export function runtimeCatalog(platform = process.platform, arch = process.arch, kernel = release()) {
13
+ const jdk = jdks[platform + '-' + arch];
14
+ if (!jdk)
15
+ fail('configuration', 'No verified managed runtime for this OS/architecture; use an external compatible Ghidra installation.');
16
+ const windows = platform === 'win32', format = windows ? 'zip' : 'tgz', extension = windows ? 'zip' : 'tar.gz';
17
+ const java = { name: 'temurin-21.0.11+10-' + platform + '-' + arch, url: github + 'adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_' + jdk[0] + '_hotspot_21.0.11_10.' + extension, sha256: jdk[1], format, root: 'jdk-21.0.11+10' + (platform === 'darwin' ? '.jdk/Contents/Home' : ''), license: 'GPL-2.0 WITH Classpath-exception-2.0; included legal notices' };
18
+ let ghidra = ghidraArchive;
19
+ if (platform === 'darwin') {
20
+ const major = Number(kernel.split('.')[0]);
21
+ if (major < 23)
22
+ fail('configuration', 'Managed macOS runtimes require macOS 14 or newer.');
23
+ const tag = arch === 'x64' ? 'sonoma' : major >= 25 ? 'arm64_tahoe' : major >= 24 ? 'arm64_sequoia' : 'arm64_sonoma', hash = bottles[tag];
24
+ ghidra = { name: 'ghidra-12.1.2-' + tag, url: 'https://ghcr.io/v2/homebrew/core/ghidra/blobs/sha256:' + hash, sha256: hash, format: 'tgz', root: 'ghidra/12.1.2/libexec', license: ghidraArchive.license };
25
+ }
26
+ return { schema: 1, ghidraVersion: '12.1.2', javaVersion: '21.0.11+10', platform, architecture: arch, ghidra, java };
27
+ }
@@ -0,0 +1,101 @@
1
+ import { type CommandContext, type CommandDefinition } from '@eyeauras/cli-factory';
2
+ export declare function runtimeWrite(path: string, value: unknown): Promise<void>;
3
+ /** Passive, conservative inventory: a reused PID blocks deletion; it never grants kill authority. */
4
+ export declare function runtimeProcesses(directory: string): Promise<Record<string, unknown>[]>;
5
+ export declare function externalGhidra(path: string): Promise<string>;
6
+ export declare class RuntimeManager {
7
+ private readonly c;
8
+ readonly directory: string;
9
+ readonly root: string;
10
+ constructor(c: CommandContext);
11
+ status(): Promise<{
12
+ missing: string[];
13
+ capabilities: {
14
+ headless: boolean;
15
+ decompiler: boolean;
16
+ gui: boolean;
17
+ debugger: {
18
+ backend: string;
19
+ requires: string;
20
+ checkedBy: string;
21
+ } | {
22
+ backend: string;
23
+ checkedBy: string;
24
+ requires?: undefined;
25
+ };
26
+ };
27
+ server: {
28
+ pid?: unknown;
29
+ mode: string;
30
+ state: string;
31
+ };
32
+ processes: Record<string, unknown>[];
33
+ installations: {
34
+ id: string;
35
+ selected: boolean;
36
+ ghidraVersion: unknown;
37
+ verifiedAt: unknown;
38
+ }[];
39
+ retainedStages: string[];
40
+ lastCheck: any;
41
+ storage: {
42
+ runtime: string;
43
+ research: string;
44
+ materials: string;
45
+ };
46
+ installation?: string | undefined;
47
+ fingerprint?: string | undefined;
48
+ state: string;
49
+ mode: string;
50
+ recommended: string;
51
+ ghidraVersion: string | undefined;
52
+ java: string | undefined;
53
+ ghidraHome: string | undefined;
54
+ }>;
55
+ download(offline?: boolean): Promise<{
56
+ state: string;
57
+ ghidraVersion: string;
58
+ javaVersion: string;
59
+ items: {
60
+ name: string;
61
+ path: string;
62
+ sha256: string;
63
+ license: string;
64
+ }[];
65
+ }>;
66
+ private archive;
67
+ private cleanupProbes;
68
+ install(o: Record<string, unknown>): Promise<{
69
+ checks: string[];
70
+ schema: 1;
71
+ mode: "managed" | "external";
72
+ id?: string;
73
+ ghidraHome: string;
74
+ java: string;
75
+ fingerprint: string;
76
+ selectedAt: string;
77
+ uninstalledAt?: string;
78
+ state: string;
79
+ }>;
80
+ uninstall(purgeCache?: boolean): Promise<{
81
+ state: string;
82
+ mode: "external" | "managed";
83
+ externalInstallationPreserved: boolean;
84
+ researchPreserved: boolean;
85
+ materialsPreserved: boolean;
86
+ cachePreserved: boolean;
87
+ }>;
88
+ private probe;
89
+ doctor(): Promise<{
90
+ state: string;
91
+ fingerprint: string;
92
+ ghidraVersion: string;
93
+ checkedAt: string;
94
+ headless: unknown;
95
+ decompiler: unknown;
96
+ server: boolean;
97
+ debugger: Record<string, unknown>;
98
+ diagnostics: string;
99
+ }>;
100
+ }
101
+ export declare function runtimeCommands(exclusive: <T>(c: CommandContext, action: () => Promise<T>, quiescent?: boolean) => Promise<T>): CommandDefinition[];