Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3cd103b31 | ||
|
|
94e360c11b | ||
|
|
7b9ab778a1 | ||
|
|
a92b960f6f | ||
|
|
da49ea2692 | ||
|
|
f993d33028 |
@@ -103,6 +103,11 @@ architectures, build_metadata, load_commands, dependencies, imports, exports,
|
||||
segments and provenance. `source_exhaustive: false` means unreturned symbols
|
||||
may exist.
|
||||
|
||||
`decode_interface_builder` graphs are often several megabytes and can exceed the
|
||||
MCP response budget. Select `detail: "summary"` for every decoded document and
|
||||
node/edge counts, then page `view: {kind: "interface-builder", collection:
|
||||
"edges", document: "<documents[].relative_path>", offset: 0, limit: 64}`.
|
||||
|
||||
To see which functions or Objective-C methods a Mach-O actually calls in one
|
||||
run, use `observe_native_calls` with explicit breakpoints and a bounded
|
||||
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
|
||||
|
||||
@@ -294,11 +294,7 @@ the first native server into an empty table, it copies the fallback's
|
||||
`mcpServers` entries into the native table so they keep loading, and leaves
|
||||
the fallback file untouched. Existing REA environment overrides are preserved
|
||||
when refreshing its command. A malformed or unreadable fallback file fails setup instead of
|
||||
being silently suppressed; repair or remove it and rerun. Uninstall removes an
|
||||
REA-owned `rea` entry from both the native table and the fallback, backing up
|
||||
each changed file, so an emptied native table cannot load a leftover fallback
|
||||
registration; other fallback servers, settings and a foreign `rea` entry are
|
||||
kept. ZCode disables a
|
||||
being silently suppressed; repair or remove it and rerun. ZCode disables a
|
||||
server with `enable: false` on its entry; doctor reports such a registration
|
||||
as stale, and rerunning setup removes the flag to repair it. Native configuration
|
||||
uses strict JSON; setup refuses comments, trailing commas and a BOM rather than
|
||||
|
||||
@@ -129,150 +129,6 @@ Setup pins package-runner MCP registrations to the exact installed REA version,
|
||||
installs the matching skill and on-demand references in the same plan, and adds
|
||||
`startup_timeout_sec = 30` for Codex and Grok Build.
|
||||
|
||||
## Limit REA to one project or agent profile
|
||||
|
||||
Setup registers REA for the whole client. For Claude Code it writes
|
||||
`mcpServers.rea` in `~/.claude.json` and installs the bundled skill under
|
||||
`~/.claude/skills`, so every project that Claude Code opens can call REA. To
|
||||
offer REA's tools only where they're wanted, use one of the routes below.
|
||||
|
||||
Both routes control which sessions discover REA's MCP server and skill. They
|
||||
leave shell commands, filesystem access and network access available. An agent
|
||||
under the same OS account can run `rea` from `PATH` or `npx` and read other
|
||||
profiles and REA output. For clean-room separation, use an implementation
|
||||
environment with filesystem and network restrictions that prevent reading or
|
||||
fetching REA, the target and analysis output, including through shared
|
||||
installations and package registries. A separate account or container must
|
||||
enforce those restrictions.
|
||||
|
||||
For Claude Code, check existing default-profile state before choosing either
|
||||
route. Setup is additive: a previous user-level REA registration and personal
|
||||
skill remain active in other projects. Back up the default configuration and
|
||||
REA skill first, then, with `CLAUDE_CONFIG_DIR` unset, remove the REA user
|
||||
registration with `claude mcp remove --scope user rea` and remove the known
|
||||
REA-installed `~/.claude/skills/reverse-engineer-anything` directory. Preserve
|
||||
unrelated registrations and skills. `rea uninstall` applies to other clients
|
||||
as well, so it is broader than this Claude-only migration.
|
||||
|
||||
**A separate Claude Code profile.** Claude Code keeps settings, skills and session
|
||||
history under `CLAUDE_CONFIG_DIR`. Setup follows the same
|
||||
variable: it writes the registration to `$CLAUDE_CONFIG_DIR/.claude.json` and
|
||||
the skill to `$CLAUDE_CONFIG_DIR/skills`.
|
||||
|
||||
```bash
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea npx rea-agents setup --client claude_code
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea claude
|
||||
```
|
||||
|
||||
A new profile may need authentication. Console sign-ins without an API key are
|
||||
stored outside `CLAUDE_CONFIG_DIR`; see
|
||||
[Claude Code authentication](https://code.claude.com/docs/en/authentication#log-in-with-multiple-accounts).
|
||||
Sessions started without the variable use the default profile's registration,
|
||||
skill and history.
|
||||
|
||||
Inline assignments apply only to that command. Keep the same prefix for every
|
||||
profile check, setup rerun, update, or removal; bare commands use the current
|
||||
environment and can otherwise inspect or recreate default-profile state:
|
||||
|
||||
```bash
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea claude mcp list
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea npx rea-agents doctor --client claude_code --skill --json
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea npx -y rea-agents@latest setup --client claude_code --dry-run
|
||||
```
|
||||
|
||||
After reviewing an update plan, rerun that same prefixed setup command without
|
||||
`--dry-run` and approve its exact changes. Profile removal likewise needs the
|
||||
prefix on `claude mcp remove --scope user rea` and any `rea uninstall` command;
|
||||
keep the broader client scope of uninstall in mind. Restart the selected
|
||||
profile after changing its pinned registration.
|
||||
|
||||
`claude mcp remove` removes only the registration. To remove REA from this
|
||||
profile without running the broader uninstall flow, first back up the profile's
|
||||
REA-managed skill directory, then remove
|
||||
`$CLAUDE_CONFIG_DIR/skills/reverse-engineer-anything` as well. Keep other skills
|
||||
in the profile intact.
|
||||
|
||||
The commands above use POSIX shell assignment syntax. In native Windows
|
||||
PowerShell, select the profile for the whole setup/launch/check session and
|
||||
restore the previous selection afterward:
|
||||
|
||||
```powershell
|
||||
$previousClaudeConfigDir = $env:CLAUDE_CONFIG_DIR
|
||||
try {
|
||||
$env:CLAUDE_CONFIG_DIR = Join-Path $HOME ".claude-rea"
|
||||
npx rea-agents setup --client claude_code
|
||||
claude mcp list
|
||||
npx rea-agents doctor --client claude_code --skill --json
|
||||
npx -y rea-agents@latest setup --client claude_code --dry-run
|
||||
# After reviewing the plan, repeat setup without --dry-run and approve it.
|
||||
claude
|
||||
# Profile removal, when intended, also belongs inside this selected scope:
|
||||
# claude mcp remove --scope user rea
|
||||
} finally {
|
||||
if ($null -eq $previousClaudeConfigDir) {
|
||||
Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:CLAUDE_CONFIG_DIR = $previousClaudeConfigDir
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use PowerShell for these Windows examples rather than pasting POSIX assignments
|
||||
into cmd.exe. Keep profile updates, readiness checks and removal inside the
|
||||
same selected environment; the `finally` block restores the previous profile
|
||||
or unsets the variable when it was absent.
|
||||
|
||||
**One project.** Install without configuring Claude Code: use the curl
|
||||
installer's `--no-setup`, or run setup only for other clients. Then register
|
||||
REA from the project directory at Claude Code's local scope.
|
||||
|
||||
This route uses the default Claude profile. Keep `CLAUDE_CONFIG_DIR` unset for
|
||||
the entire local lifecycle: registration, backup, listing, version replacement
|
||||
and removal. In POSIX shells run `unset CLAUDE_CONFIG_DIR`; in PowerShell run
|
||||
`Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue` before these
|
||||
commands. Record an existing selection first if you intend to restore it after
|
||||
finishing. The configuration path below is the default profile's
|
||||
`~/.claude.json`; a different active profile uses its own config root instead.
|
||||
|
||||
Register with the variable unset:
|
||||
|
||||
<!-- x-release-please-start-version -->
|
||||
|
||||
```bash
|
||||
claude mcp add --scope local rea -- npx -y rea-agents@6.4.0 mcp
|
||||
```
|
||||
|
||||
On native Windows, use the same project directory and wrap the package-runner
|
||||
shim so the stdio client can launch it:
|
||||
|
||||
```powershell
|
||||
claude mcp add --scope local rea -- cmd.exe /d /c npx -y rea-agents@6.4.0 mcp
|
||||
```
|
||||
|
||||
<!-- x-release-please-end -->
|
||||
|
||||
Local scope stores the entry under that directory's path in `~/.claude.json`,
|
||||
so other projects don't load it. This route doesn't install the bundled skill.
|
||||
Avoid `--scope project`, which writes `.mcp.json` into the repository where it
|
||||
can be committed or copied. `rea doctor` and `rea uninstall` read only the
|
||||
user-level entry, so they don't report or remove a local-scope registration;
|
||||
remove it with `claude mcp remove --scope local rea` from the same directory.
|
||||
`rea update` doesn't refresh it either, so it stays on the version it names.
|
||||
To move it to a newer release, first back up the project entry in
|
||||
`~/.claude.json` and record its environment and other metadata. In particular,
|
||||
preserve caller-selected overrides such as `REA_MCP_INPUT_SCHEMA_PROFILE` and
|
||||
`REA_MCP_MAX_RESPONSE_BYTES`. Run `claude mcp remove --scope local rea` from the
|
||||
same directory, then recreate it with the platform-appropriate command above
|
||||
and the new version. Reapply every recorded environment value with Claude
|
||||
Code's `mcp add --env NAME=value` options and restore any other retained entry
|
||||
metadata before restarting the project session. Removal and re-addition do not
|
||||
preserve these values automatically. Running setup for Claude Code instead
|
||||
would add a user-level entry for every project.
|
||||
|
||||
Check the separate profile with the prefixed `claude mcp list` above and compare
|
||||
with the default profile with `CLAUDE_CONFIG_DIR` unset. For local scope, run
|
||||
`claude mcp list` from the selected project and again from another project.
|
||||
|
||||
## Check readiness for your task
|
||||
|
||||
Run `rea doctor` when you need diagnosis. It reads host prerequisites and agent
|
||||
|
||||
+13
-1
@@ -193,7 +193,8 @@ after target content was read.
|
||||
## Retained application Evidence inputs
|
||||
|
||||
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
|
||||
`inspect_wasm_artifact`, and `inspect_macho` accept `detail`:
|
||||
`inspect_wasm_artifact`, `inspect_macho`, and `decode_interface_builder` accept
|
||||
`detail`:
|
||||
`complete` (default) returns the complete analysis Evidence; `summary` records
|
||||
that complete Evidence in the session and returns the derived
|
||||
`inspect_analysis_view` summary Evidence instead. Its
|
||||
@@ -276,6 +277,17 @@ symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a
|
||||
summary), so select `detail: "summary"` on the first call. Delivery detail
|
||||
never reaches the native tools or changes the source Evidence ID.
|
||||
|
||||
Completed `decode_interface_builder` Evidence supports a summary and
|
||||
`view: {kind: "interface-builder", collection: "nodes", offset: 0, limit: 64}`.
|
||||
Collections are `documents`, `nodes`, `edges`, and `coverage`; for nodes and
|
||||
edges an optional `document` keeps rows whose evidence names that exact decoded
|
||||
`relative_path`, and an undecoded path is refused. The summary keeps every
|
||||
decoded document, the provider, node-kind, edge-relation and coverage-status
|
||||
counts, and whether the decoded graph is incomplete. Each page repeats `source_truncated`. A complete
|
||||
app graph often exceeds the default MCP response budget (XLD.app: 6.7 MB), so
|
||||
select `detail: "summary"` on the first call. Delivery detail never reaches the
|
||||
decoder or changes the source Evidence ID.
|
||||
|
||||
`trace_application_feature`, `trace_javascript_semantics`,
|
||||
`compare_application_versions`, `compare_source_to_bundle`, and
|
||||
`compare_javascript_export_shapes` accept complete inline application Evidence
|
||||
|
||||
@@ -319,7 +319,16 @@ complete observation.
|
||||
digest are preserved; key interpretation follows the pinned plist decoder.
|
||||
- `decode_interface_builder` / `rea decode-interface-builder <app>` reads
|
||||
compiled storyboard and nib resources into objects, outlets, actions,
|
||||
connections and controller/class names. `verify:interface-builder` compiles
|
||||
connections and controller/class names. A full app graph is often several
|
||||
megabytes (XLD.app: 6.7 MB, about 2M tokens, which also exceeds the default
|
||||
MCP response budget), so MCP callers should pass `detail: "summary"`. The
|
||||
summary keeps every decoded document and node/edge/coverage counts and
|
||||
retains the complete Evidence; page rows with `inspect_analysis_view`
|
||||
`view: {kind: "interface-builder", collection: "nodes", document:
|
||||
"Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`.
|
||||
`document` selects rows whose evidence names that exact
|
||||
`documents[].relative_path` (nodes and edges only); `source_truncated` keeps
|
||||
graph incompleteness visible on every page. `verify:interface-builder` compiles
|
||||
a source-owned AppKit XIB with `ibtool`. Storyboards require an installed
|
||||
iOS platform; unsupported archive forms remain explicit.
|
||||
XML plist archives accept UTF-8 and BOM-marked UTF-16 in either byte order.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rmdir,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
@@ -55,7 +54,7 @@ try {
|
||||
join(workspace, "dist", "windows", "WindowsNativeLoader.js"),
|
||||
).href;
|
||||
const source = `import assert from 'node:assert/strict';
|
||||
import {existsSync,mkdirSync,readFileSync,readdirSync,renameSync,statSync,writeFileSync} from 'node:fs';
|
||||
import {existsSync,mkdirSync,readdirSync,renameSync,statSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)};
|
||||
const temp=process.env.TEMP;
|
||||
@@ -65,61 +64,46 @@ assert.equal(first.retryable,true);
|
||||
assert.match(first.reason,/Delete owned object by handle failed.*Win32 5/s);
|
||||
const [name,...extra]=readdirSync(temp);assert.ok(name);assert.equal(extra.length,0);
|
||||
const path=join(temp,name);const identity=statSync(path,{bigint:true});
|
||||
// Delete sharing permits rename; ownership follows the retained NTFS handle.
|
||||
writeFileSync(join(path,'owned-child.txt'),'owned child');
|
||||
const renamed=path+'-renamed';renameSync(path,renamed);
|
||||
const moved=statSync(renamed,{bigint:true});
|
||||
assert.equal(moved.dev,identity.dev);assert.equal(moved.ino,identity.ino);
|
||||
// Ownership follows the retained NTFS handle even when the directory moves.
|
||||
// A replacement at its old pathname must remain caller-owned.
|
||||
const moved=path+'-renamed';renameSync(path,moved);
|
||||
assert.equal(statSync(moved,{bigint:true}).ino,identity.ino);
|
||||
mkdirSync(path);const replacement=statSync(path,{bigint:true});
|
||||
assert.notEqual(replacement.ino,identity.ino);
|
||||
const sentinel=join(path,'unrelated.txt');writeFileSync(sentinel,'unrelated replacement');
|
||||
if(process.argv[2]!=='teardown'){
|
||||
const second=systemWindowsNativeAuthority();assert.equal(second.available,true);
|
||||
assert.equal(existsSync(renamed),false);
|
||||
assert.equal(existsSync(moved),false);
|
||||
assert.deepEqual(readdirSync(temp),[name]);
|
||||
assert.equal(readFileSync(sentinel,'utf8'),'unrelated replacement');
|
||||
assert.equal(statSync(path,{bigint:true}).ino,replacement.ino);
|
||||
assert.equal(systemWindowsNativeAuthority(),second);
|
||||
}
|
||||
console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,name,renamed,device:identity.dev.toString(),inode:identity.ino.toString(),replacementInode:replacement.ino.toString()}));
|
||||
console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,inode:identity.ino.toString(),replacementName:name,replacementInode:replacement.ino.toString()}));
|
||||
`;
|
||||
const fixture = join(workspace, "probe.mjs");
|
||||
await writeFile(fixture, source);
|
||||
for (const mode of ["retry", "teardown"]) {
|
||||
const temp = join(workspace, mode);
|
||||
await mkdir(temp);
|
||||
const child = spawnSync(process.execPath, [fixture, mode], {
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, TEMP: temp, TMP: temp },
|
||||
});
|
||||
if (child.error !== undefined) throw child.error;
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(
|
||||
child.stderr,
|
||||
"",
|
||||
"Native environment teardown reported a cleanup failure",
|
||||
const observed = JSON.parse(
|
||||
execFileSync(process.execPath, [fixture, mode], {
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, TEMP: temp, TMP: temp },
|
||||
}),
|
||||
);
|
||||
const observed = JSON.parse(child.stdout);
|
||||
assert.equal(observed.mode, mode);
|
||||
assert.equal(observed.firstFailed, true);
|
||||
assert.equal(observed.retainedOwner, true);
|
||||
await assert.rejects(access(observed.renamed), { code: "ENOENT" });
|
||||
assert.deepEqual(await readdir(temp), [observed.name]);
|
||||
const replacement = join(temp, observed.name);
|
||||
const identity = await stat(replacement, { bigint: true });
|
||||
assert.equal(identity.dev.toString(), observed.device);
|
||||
assert.equal(identity.ino.toString(), observed.replacementInode);
|
||||
assert.notEqual(identity.ino.toString(), observed.inode);
|
||||
assert.deepEqual(await readdir(replacement), ["unrelated.txt"]);
|
||||
assert.deepEqual(await readdir(temp), [observed.replacementName]);
|
||||
const replacement = join(temp, observed.replacementName);
|
||||
assert.equal(
|
||||
await readFile(join(replacement, "unrelated.txt"), "utf8"),
|
||||
"unrelated replacement",
|
||||
(await stat(replacement, { bigint: true })).ino.toString(),
|
||||
observed.replacementInode,
|
||||
);
|
||||
await rmdir(replacement);
|
||||
assert.deepEqual(await readdir(temp), []);
|
||||
}
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, renamedOwnerCleansChildren: true, unrelatedReplacementPreserved: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
|
||||
`${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, renamedOwnerCleanup: true, replacementPreserved: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
|
||||
@@ -167,7 +167,7 @@ export const parseZcodeFallbackConfiguration = (
|
||||
? {}
|
||||
: requireObject(document.mcpServers, "ZCode fallback mcpServers"),
|
||||
serversPath: ["mcpServers"],
|
||||
dialect: "zcode_fallback",
|
||||
dialect: "json",
|
||||
legacyServers: {},
|
||||
};
|
||||
};
|
||||
@@ -183,7 +183,6 @@ export const parseClientConfiguration = (
|
||||
if (format === undefined || format === "unsupported")
|
||||
throw new TypeError("client does not have a supported MCP config format");
|
||||
// DeepSeek Harness stores a Cordis patch list, not a server table.
|
||||
if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text);
|
||||
if (format === "dsh") return parseDshCordisDocument(text);
|
||||
const document = parseDocument(text, format);
|
||||
const serversKey = clientConfigurationServersKey(format);
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
|
||||
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
||||
import { readReferenceSource } from "../reference/ReferenceSourceReader.js";
|
||||
import type {
|
||||
ReferenceSourceExclusionKind,
|
||||
ReferenceSourceEntryKind,
|
||||
ReferenceSourceRead,
|
||||
ReferenceSourceReaderError,
|
||||
} from "../reference/ReferenceSourceReaderTypes.js";
|
||||
@@ -111,8 +111,7 @@ const deriveInventoryState = (
|
||||
entry.limitations.length > 0 ||
|
||||
(entry.kind === "file" && entry.content_state !== "hashed") ||
|
||||
(entry.kind === "directory" && entry.tree_state !== "enumerated") ||
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal") ||
|
||||
entry.kind === "unknown",
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal"),
|
||||
);
|
||||
if (partialEntry) return "partial";
|
||||
const partialRelationship = input.relationships.some(
|
||||
@@ -143,24 +142,11 @@ const createShouldExclude =
|
||||
exclusions: HistoricalSourceGraphInput["exclusions"],
|
||||
secrets: PreparedReferenceSourceImport["secrets"],
|
||||
ignored: PreparedReferenceSourceImport["ignored"],
|
||||
): ((path: string, kind: ReferenceSourceExclusionKind) => boolean) =>
|
||||
): ((path: string, kind: ReferenceSourceEntryKind) => boolean) =>
|
||||
(path, kind) => {
|
||||
// An entry of unknown kind may be a directory, so directory-only rules
|
||||
// must still exclude it; this fails closed for caller-marked secrets.
|
||||
const patternPaths =
|
||||
kind === "directory"
|
||||
? [`${path}/`]
|
||||
: kind === "unknown"
|
||||
? [path, `${path}/`]
|
||||
: [path];
|
||||
const firstMatch = (matcher: typeof secrets) => {
|
||||
const results = patternPaths.map((patternPath) =>
|
||||
matcher.test(patternPath),
|
||||
);
|
||||
return results.find(({ ignored }) => ignored) ?? results[0];
|
||||
};
|
||||
const secretMatch = firstMatch(secrets);
|
||||
if (secretMatch?.ignored === true) {
|
||||
const patternPath = kind === "directory" ? `${path}/` : path;
|
||||
const secretMatch = secrets.test(patternPath);
|
||||
if (secretMatch.ignored) {
|
||||
if (!secretMatch.rule)
|
||||
throw new Error(`Ignored secret path has no matching rule: ${path}`);
|
||||
exclusions.push({
|
||||
@@ -170,8 +156,8 @@ const createShouldExclude =
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const match = firstMatch(ignored);
|
||||
if (match?.ignored !== true) return false;
|
||||
const match = ignored.test(patternPath);
|
||||
if (!match.ignored) return false;
|
||||
if (!match.rule)
|
||||
throw new Error(`Ignored path has no matching rule: ${path}`);
|
||||
const reason = match.rule.mark;
|
||||
|
||||
@@ -45,8 +45,6 @@ const entryRecoveryGuidance = (
|
||||
return "This directory could not be read. Check its permissions, then try again.";
|
||||
if (entry.kind === "symlink")
|
||||
return "This symbolic link could not be read safely. Check the link and its permissions, then try again.";
|
||||
if (entry.kind === "unknown")
|
||||
return "This entry's type could not be determined. Check the parent directory and try again.";
|
||||
return "This file could not be read. Check its permissions, then try again.";
|
||||
};
|
||||
|
||||
@@ -75,13 +73,6 @@ const failedEntry = (
|
||||
classifications,
|
||||
limitations: [limitation],
|
||||
};
|
||||
if (entry.kind === "unknown")
|
||||
return {
|
||||
path: entry.path,
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: [limitation],
|
||||
};
|
||||
return {
|
||||
path: entry.path,
|
||||
kind: "file",
|
||||
|
||||
@@ -47,7 +47,6 @@ export interface SetupClient {
|
||||
| "pi"
|
||||
| "kimi"
|
||||
| "hermes"
|
||||
| "zcode_fallback"
|
||||
| "zcode"
|
||||
| "dsh"
|
||||
| "unsupported";
|
||||
|
||||
@@ -231,21 +231,12 @@ export const systemUninstallHost = (
|
||||
const clients = supportedClients(home, platform, environment);
|
||||
redirection = appDataRedirection(clients);
|
||||
return Promise.resolve(
|
||||
clients.flatMap((client) => [
|
||||
...uninstallConfigPaths(client).map((configPath) => ({
|
||||
clients.flatMap((client) =>
|
||||
uninstallConfigPaths(client).map((configPath) => ({
|
||||
...client,
|
||||
configPath,
|
||||
})),
|
||||
...(client.zcodeFallbackPath === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
...client,
|
||||
configPath: client.zcodeFallbackPath,
|
||||
format: "zcode_fallback" as const,
|
||||
},
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
inspectClient: async (client) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -89,11 +89,17 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
),
|
||||
artifact(
|
||||
"decode_interface_builder",
|
||||
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns.",
|
||||
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns. A full app graph is often several megabytes; detail summary retains it and returns documents with node, edge and coverage counts, then page rows, optionally one document's, with inspect_analysis_view kind interface-builder.",
|
||||
z.strictObject({
|
||||
max_documents: z.number().int().min(1).max(64).default(64),
|
||||
max_objects: z.number().int().min(1).max(20_000).default(20_000),
|
||||
max_connections: z.number().int().min(1).max(40_000).default(40_000),
|
||||
detail: analysisDetailSchema
|
||||
.unwrap()
|
||||
.optional()
|
||||
.describe(
|
||||
"Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
artifact(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { byteSearchResultSchema } from "../domain/native/bytePattern.js";
|
||||
import {
|
||||
artifactInventorySummaryViewResultSchema,
|
||||
interfaceBuilderSummaryViewResultSchema,
|
||||
machoSummaryViewResultSchema,
|
||||
managedArtifactSummaryViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
@@ -404,7 +405,12 @@ export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
]),
|
||||
),
|
||||
extract_artifact: evidenceResultOf(artifactExtractionResultSchema),
|
||||
decode_interface_builder: evidenceResultOf(interfaceBuilderAnalysisSchema),
|
||||
decode_interface_builder: evidenceResultOf(
|
||||
z.union([
|
||||
interfaceBuilderAnalysisSchema,
|
||||
interfaceBuilderSummaryViewResultSchema,
|
||||
]),
|
||||
),
|
||||
inspect_keyed_archive: evidenceResultOf(keyedArchiveResultSchema),
|
||||
inspect_asset_catalog: evidenceResultOf(appleAssetCatalogResultSchema),
|
||||
trace_dylib_resolution: evidenceResultOf(dylibResolutionResultSchema),
|
||||
|
||||
@@ -62,6 +62,13 @@ import {
|
||||
machoViewRequestSchema,
|
||||
projectMachoView,
|
||||
} from "./machoView.js";
|
||||
import {
|
||||
interfaceBuilderSummarySchema,
|
||||
interfaceBuilderViewContextSchema,
|
||||
interfaceBuilderViewItemsSchema,
|
||||
interfaceBuilderViewRequestSchema,
|
||||
projectInterfaceBuilderView,
|
||||
} from "./interfaceBuilderView.js";
|
||||
import {
|
||||
projectWebNetworkCaptureView,
|
||||
captureViewRequestSchema,
|
||||
@@ -206,6 +213,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
inventoryViewSchema,
|
||||
wasmViewRequestSchema,
|
||||
machoViewRequestSchema,
|
||||
interfaceBuilderViewRequestSchema,
|
||||
evmViewRequestSchema,
|
||||
managedViewRequestSchema,
|
||||
androidViewRequestSchema,
|
||||
@@ -326,6 +334,7 @@ const viewResultBase = {
|
||||
"inventory_artifact",
|
||||
"inspect_wasm_artifact",
|
||||
"inspect_macho",
|
||||
"decode_interface_builder",
|
||||
"inspect_evm_interface",
|
||||
"inspect_managed_artifact",
|
||||
"inspect_web_network_capture",
|
||||
@@ -382,6 +391,13 @@ export const machoSummaryViewResultSchema = z.strictObject({
|
||||
summary: machoSummarySchema,
|
||||
});
|
||||
|
||||
/** Decoded documents and graph counts for Interface Builder's selected delivery. */
|
||||
export const interfaceBuilderSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("decode_interface_builder"),
|
||||
summary: interfaceBuilderSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact inferred-interface summary for EVM's caller-selected delivery. */
|
||||
export const evmInterfaceSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
@@ -444,6 +460,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
inventorySummarySchema,
|
||||
wasmSummarySchema,
|
||||
machoSummarySchema,
|
||||
interfaceBuilderSummarySchema,
|
||||
evmSummarySchema,
|
||||
managedSummarySchema,
|
||||
captureSummarySchema,
|
||||
@@ -514,6 +531,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
...machoViewContextSchema.shape,
|
||||
parent_operation: z.literal("inspect_macho"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("interface-builder"),
|
||||
view: interfaceBuilderViewRequestSchema,
|
||||
items: interfaceBuilderViewItemsSchema,
|
||||
...viewResultBase,
|
||||
...interfaceBuilderViewContextSchema.shape,
|
||||
parent_operation: z.literal("decode_interface_builder"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("evm"),
|
||||
view: evmViewRequestSchema,
|
||||
@@ -611,6 +636,10 @@ export type UnsignedAnalysisView =
|
||||
>
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "wasm" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "macho" }>, "view_digest">
|
||||
| Omit<
|
||||
Extract<AnalysisViewResult, { readonly kind: "interface-builder" }>,
|
||||
"view_digest"
|
||||
>
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "evm" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "qlog" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "pcap" }>, "view_digest">
|
||||
@@ -674,7 +703,7 @@ const unsupportedParent = (
|
||||
new AnalysisUnsupportedTargetError(
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parentArtifactPath(parent),
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
);
|
||||
|
||||
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
@@ -702,6 +731,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
||||
["inspect_wasm_artifact", projectWasmArtifactView],
|
||||
["inspect_macho", projectMachoView],
|
||||
["decode_interface_builder", projectInterfaceBuilderView],
|
||||
["inspect_evm_interface", projectEvmInterfaceView],
|
||||
["inspect_managed_artifact", projectManagedArtifactView],
|
||||
["inspect_android_package", projectAndroidPackageView],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import type { AnalysisViewParent } from "./analysisView.js";
|
||||
|
||||
/** Parse a parent's result with its owning schema; issues point into the view source. */
|
||||
export const parseAnalysisViewResult = <Schema extends z.ZodType>(
|
||||
schema: Schema,
|
||||
parent: AnalysisViewParent,
|
||||
): Result<z.output<Schema>, AnalysisError> => {
|
||||
const parsed = schema.safeParse(parent.normalizedResult);
|
||||
if (parsed.success) return ok(parsed.data);
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { androidResultSchemas } from "../android/androidAnalysis.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -53,19 +53,8 @@ export const projectAndroidPackageView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = packageSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const parsed = parseAnalysisViewResult(packageSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
// Engine artifact_sha256 identifies the JAR, never the APK. A missing
|
||||
// authenticated APK subject cannot be repaired using the engine identity.
|
||||
if (parent.subject == null)
|
||||
@@ -79,7 +68,7 @@ export const projectAndroidPackageView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const report = parsed.value;
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_android_package" as const,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -59,20 +59,9 @@ export const projectDartAotView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = aotSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(aotSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const { package_uris, dart_uris, class_like_tokens, ...pool } =
|
||||
report.string_pool;
|
||||
const string_pool = {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { evmInterfaceSchema } from "../evm/evmInterface.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -60,20 +60,9 @@ export const projectEvmInterfaceView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = evmInterfaceSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(evmInterfaceSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
parent.subject == null ||
|
||||
parent.subject.format !== "file" ||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
projectAnalysisView,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisView.js";
|
||||
|
||||
const target = "a".repeat(64);
|
||||
const evidence = (artifact_path: string) => [
|
||||
{
|
||||
kind: "interface_builder_resource" as const,
|
||||
description: "compiled document",
|
||||
location: { address: null, file_offset: null },
|
||||
artifact_path,
|
||||
artifact_sha256: "b".repeat(64),
|
||||
},
|
||||
];
|
||||
const node = (id: string, kind: string, document: string) => ({
|
||||
id,
|
||||
kind,
|
||||
name: id,
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence: evidence(document),
|
||||
});
|
||||
const main = "Contents/Resources/Main.nib";
|
||||
const second = "Contents/Resources/Second.nib";
|
||||
const document = (relative_path: string) => ({
|
||||
relative_path,
|
||||
archive_sha256: "b".repeat(64),
|
||||
document_kind: "nib" as const,
|
||||
object_count: 2,
|
||||
connection_count: 1,
|
||||
hierarchy_complete: true,
|
||||
});
|
||||
const report = (truncated = false) => ({
|
||||
target_sha256: target,
|
||||
documents: [document(main), document(second)],
|
||||
graph: {
|
||||
target_sha256: target,
|
||||
provider: { id: "rea-ib", version: "1", tool_version: "1" },
|
||||
nodes: [
|
||||
node("main:root", "scene", main),
|
||||
node("main:button", "control", main),
|
||||
node("second:root", "scene", second),
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "main:edge",
|
||||
from: "main:root",
|
||||
to: "main:button",
|
||||
relation: "contains",
|
||||
resolution: "observed",
|
||||
evidence: evidence(main),
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
coverage: [
|
||||
{
|
||||
facet: `objects:${main}`,
|
||||
status: "complete",
|
||||
reason: null,
|
||||
examined: 2,
|
||||
omitted: 0,
|
||||
},
|
||||
],
|
||||
truncated,
|
||||
},
|
||||
limitations: ["Recognized archive fields only."],
|
||||
});
|
||||
const parent = (
|
||||
normalizedResult: unknown = report(),
|
||||
sha256 = target,
|
||||
): AnalysisViewParent => ({
|
||||
evidenceId: `ev_${"c".repeat(64)}`,
|
||||
operation: "decode_interface_builder",
|
||||
normalizedResult,
|
||||
subject: {
|
||||
name: "Fixture",
|
||||
digest: { sha256 },
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
local_path: "/Applications/Fixture.app/Contents/MacOS/Fixture",
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
describe("Interface Builder analysis views", () => {
|
||||
it("summarizes documents and graph counts without rows", () => {
|
||||
const projected = projectAnalysisView(parent(), { kind: "summary" });
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_operation: "decode_interface_builder",
|
||||
artifact: { sha256: target },
|
||||
summary: {
|
||||
documents: report().documents,
|
||||
available_counts: { nodes: 3, edges: 1, coverage: 1 },
|
||||
node_kinds: { control: 1, scene: 2 },
|
||||
edge_relations: { contains: 1 },
|
||||
coverage_statuses: { complete: 1 },
|
||||
truncated: false,
|
||||
},
|
||||
coverage: { examined: 7, total: 7 },
|
||||
});
|
||||
expect(JSON.stringify(projected.value)).not.toContain("main:button");
|
||||
});
|
||||
|
||||
it("pages one document's nodes and keeps decoder truncation visible", () => {
|
||||
const projected = projectAnalysisView(parent(), {
|
||||
kind: "interface-builder",
|
||||
collection: "nodes",
|
||||
document: main,
|
||||
offset: 1,
|
||||
limit: 5,
|
||||
});
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value).toMatchObject({
|
||||
kind: "interface-builder",
|
||||
items: [node("main:button", "control", main)],
|
||||
source_truncated: false,
|
||||
coverage: { status: "page", examined: 1, total: 2, exhausted: true },
|
||||
});
|
||||
const truncated = projectAnalysisView(parent(report(true)), {
|
||||
kind: "interface-builder",
|
||||
collection: "coverage",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (!truncated.ok) throw truncated.error;
|
||||
expect(truncated.value).toMatchObject({
|
||||
items: report().graph.coverage,
|
||||
source_truncated: true,
|
||||
unknowns: expect.arrayContaining([
|
||||
expect.stringContaining("The decoded graph is incomplete"),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses unknown documents, other views and mismatched targets", () => {
|
||||
const unknown = projectAnalysisView(parent(), {
|
||||
kind: "interface-builder",
|
||||
collection: "edges",
|
||||
document: "Contents/Resources/Missing.nib",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (unknown.ok) throw new Error("expected an unknown-document refusal");
|
||||
expect(unknown.error).toMatchObject({
|
||||
issues: [{ path: ["view", "document"] }],
|
||||
});
|
||||
const wrongKind = projectAnalysisView(parent(), {
|
||||
kind: "macho",
|
||||
facet: "imports",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (wrongKind.ok) throw new Error("expected a view-kind refusal");
|
||||
expect(wrongKind.error).toMatchObject({ issues: [{ path: ["view"] }] });
|
||||
const mismatched = projectAnalysisView(parent(report(), "d".repeat(64)), {
|
||||
kind: "summary",
|
||||
});
|
||||
if (mismatched.ok) throw new Error("expected a subject refusal");
|
||||
expect(mismatched.error).toMatchObject({
|
||||
issues: [{ path: ["source", "subject"] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import {
|
||||
interfaceBuilderAnalysisSchema,
|
||||
interfaceBuilderDocumentSchema,
|
||||
} from "../apple/interfaceBuilderGraph.js";
|
||||
import {
|
||||
nativeInvestigationCoverageSchema,
|
||||
nativeInvestigationEdgeSchema,
|
||||
nativeInvestigationNodeSchema,
|
||||
} from "../native/nativeInvestigationGraph.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
type AnalysisViewRequest,
|
||||
type UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
|
||||
const page = {
|
||||
kind: z.literal("interface-builder"),
|
||||
offset: z.number().int().nonnegative().default(0),
|
||||
limit: z.number().int().positive().default(64),
|
||||
} as const;
|
||||
|
||||
/** Original decoded rows; graph rows can be narrowed to one decoded document. */
|
||||
export const interfaceBuilderViewRequestSchema = z.discriminatedUnion(
|
||||
"collection",
|
||||
[
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.enum(["nodes", "edges"]),
|
||||
document: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Exact documents[].relative_path; selects rows whose evidence names that archive.",
|
||||
),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.enum(["documents", "coverage"]),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export const interfaceBuilderViewItemsSchema = z.union([
|
||||
z.array(interfaceBuilderDocumentSchema),
|
||||
z.array(nativeInvestigationNodeSchema),
|
||||
z.array(nativeInvestigationEdgeSchema),
|
||||
z.array(nativeInvestigationCoverageSchema),
|
||||
]);
|
||||
|
||||
/** The decoder's own truncation, independent of page exhaustion. */
|
||||
export const interfaceBuilderViewContextSchema = z.strictObject({
|
||||
source_truncated: z.boolean(),
|
||||
});
|
||||
|
||||
const countsSchema = z.record(z.string(), z.number().int().positive());
|
||||
|
||||
/** Every decoded document with graph counts; rows and evidence stay retained. */
|
||||
export const interfaceBuilderSummarySchema = z.strictObject({
|
||||
target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256,
|
||||
provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider,
|
||||
documents: interfaceBuilderAnalysisSchema.shape.documents,
|
||||
available_counts: z.strictObject({
|
||||
nodes: z.number().int().nonnegative(),
|
||||
edges: z.number().int().nonnegative(),
|
||||
coverage: z.number().int().nonnegative(),
|
||||
}),
|
||||
node_kinds: countsSchema,
|
||||
edge_relations: countsSchema,
|
||||
coverage_statuses: countsSchema,
|
||||
truncated: z.boolean(),
|
||||
limitation_count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const tally = (values: readonly string[]): Record<string, number> => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
return Object.fromEntries(
|
||||
[...counts].sort(([left], [right]) => (left < right ? -1 : 1)),
|
||||
);
|
||||
};
|
||||
|
||||
type InterfaceBuilderReport = z.output<typeof interfaceBuilderAnalysisSchema>;
|
||||
|
||||
/** Admit a well-formed report bound to its recorded target, if one exists. */
|
||||
const parseInterfaceBuilderReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<InterfaceBuilderReport, AnalysisError> => {
|
||||
const parsed = parseAnalysisViewResult(
|
||||
interfaceBuilderAnalysisSchema,
|
||||
parent,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
report.graph.target_sha256 !== report.target_sha256 ||
|
||||
(parent.subject != null &&
|
||||
parent.subject.digest.sha256 !== report.target_sha256)
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source", "subject"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Interface Builder parent Evidence subject and graph must identify the report's target SHA-256.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok(report);
|
||||
};
|
||||
|
||||
/** Every decoded document with graph tallies; rows stay in the retained parent. */
|
||||
const summarizeInterfaceBuilder = (
|
||||
report: InterfaceBuilderReport,
|
||||
limitationCount: number,
|
||||
) => {
|
||||
const { graph } = report;
|
||||
const total =
|
||||
report.documents.length +
|
||||
graph.nodes.length +
|
||||
graph.edges.length +
|
||||
graph.coverage.length;
|
||||
return {
|
||||
summary: {
|
||||
target_sha256: report.target_sha256,
|
||||
provider: graph.provider,
|
||||
documents: report.documents,
|
||||
available_counts: {
|
||||
nodes: graph.nodes.length,
|
||||
edges: graph.edges.length,
|
||||
coverage: graph.coverage.length,
|
||||
},
|
||||
node_kinds: tally(graph.nodes.map((node) => node.kind)),
|
||||
edge_relations: tally(graph.edges.map((edge) => edge.relation)),
|
||||
coverage_statuses: tally(graph.coverage.map((row) => row.status)),
|
||||
truncated: graph.truncated,
|
||||
limitation_count: limitationCount,
|
||||
},
|
||||
coverage: completeWithinViewCoverage(total, total),
|
||||
};
|
||||
};
|
||||
|
||||
/** Project retained decode_interface_builder Evidence without reopening archives. */
|
||||
export const projectInterfaceBuilderView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "summary" && view.kind !== "interface-builder")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Interface Builder decoding supports summary and interface-builder views.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = parseInterfaceBuilderReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const { graph } = report;
|
||||
const limitations = uniqueSorted([
|
||||
...parent.limitations,
|
||||
...report.limitations,
|
||||
]);
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "decode_interface_builder" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: {
|
||||
path: parent.subject?.local_path ?? "",
|
||||
sha256: report.target_sha256,
|
||||
},
|
||||
limitations,
|
||||
unknowns: [
|
||||
"Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.",
|
||||
...(graph.truncated
|
||||
? [
|
||||
"The decoded graph is incomplete (see coverage and limitations for the cause); page exhaustion does not establish absence of undecoded objects or connections.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
if (view.kind === "summary")
|
||||
return ok({
|
||||
kind: "summary",
|
||||
view,
|
||||
...shared,
|
||||
...summarizeInterfaceBuilder(report, limitations.length),
|
||||
});
|
||||
const document =
|
||||
view.collection === "nodes" || view.collection === "edges"
|
||||
? view.document
|
||||
: undefined;
|
||||
if (
|
||||
document !== undefined &&
|
||||
!report.documents.some((row) => row.relative_path === document)
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view", "document"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Select an exact documents[].relative_path from this decoded report.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inDocument = <
|
||||
Row extends { readonly evidence: readonly { artifact_path: unknown }[] },
|
||||
>(
|
||||
candidates: readonly Row[],
|
||||
): readonly Row[] =>
|
||||
document === undefined
|
||||
? candidates
|
||||
: candidates.filter((row) =>
|
||||
row.evidence.some((evidence) => evidence.artifact_path === document),
|
||||
);
|
||||
const rows =
|
||||
view.collection === "documents"
|
||||
? report.documents
|
||||
: view.collection === "coverage"
|
||||
? graph.coverage
|
||||
: view.collection === "nodes"
|
||||
? inDocument(graph.nodes)
|
||||
: inDocument(graph.edges);
|
||||
const items = rows.slice(view.offset, view.offset + view.limit);
|
||||
return ok({
|
||||
kind: "interface-builder",
|
||||
view,
|
||||
items,
|
||||
...shared,
|
||||
source_truncated: graph.truncated,
|
||||
coverage: pageViewCoverage(view.offset, items.length, rows.length),
|
||||
});
|
||||
};
|
||||
@@ -2,9 +2,9 @@ import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { isMachOFormat } from "../binaryTargetTypes.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -93,19 +93,8 @@ type MachoReport = z.output<typeof inspectMachoSchema>;
|
||||
const parseMachoReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<MachoReport, AnalysisError> => {
|
||||
const parsed = inspectMachoSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const parsed = parseAnalysisViewResult(inspectMachoSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (parent.subject != null && !isMachOFormat(parent.subject.format))
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
@@ -117,7 +106,7 @@ const parseMachoReport = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok(parsed.data);
|
||||
return ok(parsed.value);
|
||||
};
|
||||
|
||||
/** Counts replace rows; every collection caveat stays in the limitations. */
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import {
|
||||
managedArtifactInspectionSchema,
|
||||
managedMemberInspectionSchema,
|
||||
} from "../managed/managedArtifact.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -67,22 +67,12 @@ export const projectManagedArtifactView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = managedArtifactInspectionSchema.safeParse(
|
||||
parent.normalizedResult,
|
||||
const parsed = parseAnalysisViewResult(
|
||||
managedArtifactInspectionSchema,
|
||||
parent,
|
||||
);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const identity_scope = {
|
||||
token_identity: "build-local" as const,
|
||||
requires_artifact_sha256: report.artifact.sha256,
|
||||
|
||||
@@ -2,11 +2,11 @@ import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
wasmArtifactSchema,
|
||||
wasmArtifactMatchesSubject,
|
||||
} from "../wasm/wasmArtifact.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -81,20 +81,9 @@ export const projectWasmArtifactView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = wasmArtifactSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(wasmArtifactSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
!wasmArtifactMatchesSubject(
|
||||
report.artifact,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
webNetworkCaptureRecordSchema,
|
||||
webNetworkCaptureBinaryDescriptorSchema,
|
||||
@@ -118,20 +118,9 @@ const preview = (record: WebNetworkCaptureRecord) => {
|
||||
const parseCaptureParent = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<WebNetworkCapture, AnalysisError> => {
|
||||
const parsed = webNetworkCaptureSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(webNetworkCaptureSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
report.artifact.sha256 !== null &&
|
||||
(parent.subject == null ||
|
||||
|
||||
@@ -259,30 +259,6 @@ describe("historical source graph", () => {
|
||||
});
|
||||
|
||||
describe("partial historical source graphs", () => {
|
||||
it("keeps unknown-entry diagnostics outside the root commitment", () => {
|
||||
const base = graphInput();
|
||||
base.inventory_state = "partial";
|
||||
base.entries[2] = {
|
||||
path: "src/package.json",
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: ["Permission denied at /private/first"],
|
||||
};
|
||||
const graph = createHistoricalSourceGraph(base);
|
||||
const changedMessage = structuredClone(base);
|
||||
changedMessage.entries[2] = {
|
||||
path: "src/package.json",
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: ["Permission denied at /private/second"],
|
||||
};
|
||||
const revised = createHistoricalSourceGraph(changedMessage);
|
||||
expect(revised.root_sha256).toBe(graph.root_sha256);
|
||||
expect(computeHistoricalSourceGraphSha256(revised)).not.toBe(
|
||||
computeHistoricalSourceGraphSha256(graph),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an unreadable symlink target unknown and out of root identity", () => {
|
||||
const base = graphInput();
|
||||
base.inventory_state = "partial";
|
||||
|
||||
@@ -111,10 +111,6 @@ const sourceEntrySchema = z.union([
|
||||
sourceFileSchema,
|
||||
sourceDirectorySchema,
|
||||
sourceSymlinkSchema,
|
||||
z.strictObject({
|
||||
...entryBaseShape,
|
||||
kind: z.literal("unknown"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const sourceRelationshipSchema = z.strictObject({
|
||||
@@ -365,8 +361,7 @@ const checkCompleteInventory = (
|
||||
entry.limitations.length > 0 ||
|
||||
(entry.kind === "file" && entry.content_state !== "hashed") ||
|
||||
(entry.kind === "directory" && entry.tree_state !== "enumerated") ||
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal") ||
|
||||
entry.kind === "unknown",
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal"),
|
||||
);
|
||||
const partialRelationship = graph.relationships.some(
|
||||
({ parse_state, resolution }) =>
|
||||
@@ -483,11 +478,6 @@ const rootCommitment = (
|
||||
target: entry.target,
|
||||
target_state: entry.target_state,
|
||||
};
|
||||
case "unknown":
|
||||
return {
|
||||
kind: entry.kind,
|
||||
path: entry.path,
|
||||
};
|
||||
}
|
||||
}),
|
||||
exclusions,
|
||||
|
||||
@@ -81,26 +81,6 @@ describe("committed historical-source summaries", () => {
|
||||
expect(graph).toEqual(original);
|
||||
});
|
||||
|
||||
it("counts entries of unknown kind separately from symlinks", () => {
|
||||
const graph = createHistoricalSourceGraph(
|
||||
input([
|
||||
{
|
||||
path: "deep",
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: ["lstat: ENAMETOOLONG"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(createHistoricalSourceSummary(graph, 1).counts).toMatchObject({
|
||||
entries: 1,
|
||||
files: 0,
|
||||
directories: 0,
|
||||
symlinks: 0,
|
||||
unknown: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("sums known bytes losslessly beyond the JSON safe integer range", () => {
|
||||
const graph = createHistoricalSourceGraph(
|
||||
input(
|
||||
|
||||
@@ -29,7 +29,6 @@ export const createHistoricalSourceSummary = (
|
||||
files: 0,
|
||||
directories: 0,
|
||||
symlinks: 0,
|
||||
unknown: 0,
|
||||
relationships: graph.relationships.length,
|
||||
parse_failures: graph.parse_failures.length,
|
||||
exclusions: graph.exclusions.length,
|
||||
@@ -60,8 +59,7 @@ export const createHistoricalSourceSummary = (
|
||||
} else if (entry.kind === "directory") {
|
||||
counts.directories += 1;
|
||||
treeStates[entry.tree_state] += 1;
|
||||
} else if (entry.kind === "symlink") counts.symlinks += 1;
|
||||
else counts.unknown += 1;
|
||||
} else counts.symlinks += 1;
|
||||
// Canonical entries already use Unicode code-point order; filtering preserves it.
|
||||
if (entry.path.split("/").length <= outlineDepth)
|
||||
outline.push(outlineEntry(entry));
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import type {
|
||||
PendingDirectory,
|
||||
ReferenceSourceEntry,
|
||||
ReferenceSourceExclusionKind,
|
||||
ReferenceSourceEntryKind,
|
||||
ReferenceSourceResult,
|
||||
TraversalState,
|
||||
} from "./ReferenceSourceReaderTypes.js";
|
||||
@@ -201,13 +201,6 @@ const processEntry = async (
|
||||
const metadata = await readMetadata(absolute);
|
||||
if (isAborted(state.signal)) return { ok: false, error: cancelled() };
|
||||
if (!metadata.ok) {
|
||||
const excluded = applyExclusion(state.shouldExclude, path, "unknown");
|
||||
if (!excluded.ok)
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("io", "Reference source exclusion check failed"),
|
||||
};
|
||||
if (excluded.value) return { ok: true, value: undefined };
|
||||
state.entries.push(entryFailure(path, "unknown", "io", metadata.message));
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
@@ -378,10 +371,10 @@ const readSymlinkTarget = async (
|
||||
|
||||
const applyExclusion = (
|
||||
shouldExclude:
|
||||
| ((path: string, kind: ReferenceSourceExclusionKind) => boolean)
|
||||
| ((path: string, kind: ReferenceSourceEntryKind) => boolean)
|
||||
| undefined,
|
||||
path: string,
|
||||
kind: ReferenceSourceExclusionKind,
|
||||
kind: ReferenceSourceEntryKind,
|
||||
): { readonly ok: true; readonly value: boolean } | { readonly ok: false } => {
|
||||
try {
|
||||
return { ok: true, value: shouldExclude?.(path, kind) === true };
|
||||
|
||||
@@ -29,9 +29,6 @@ const FILESYSTEM_ERROR_CODES = new Set([
|
||||
"EISDIR",
|
||||
"ELOOP",
|
||||
"EMFILE",
|
||||
// A traversed child can exceed the platform's absolute path limit even
|
||||
// though the selected root and every component are valid.
|
||||
"ENAMETOOLONG",
|
||||
"ENFILE",
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
@@ -47,8 +44,6 @@ export const filesystemFailureDetail = (
|
||||
): string | undefined => {
|
||||
if (!(cause instanceof Error)) return undefined;
|
||||
const code: unknown = Reflect.get(cause, "code");
|
||||
if (code === "ENAMETOOLONG")
|
||||
return `${operation}: ENAMETOOLONG (entry path exceeds the operating system path limit)`;
|
||||
return typeof code === "string" && FILESYSTEM_ERROR_CODES.has(code)
|
||||
? `${operation}: ${cause.message}`
|
||||
: undefined;
|
||||
|
||||
@@ -10,17 +10,11 @@ export type ReferenceSourceEntryKind =
|
||||
| "symlink"
|
||||
| "other";
|
||||
|
||||
/**
|
||||
* Kind offered to exclusion predicates. `unknown` marks an entry whose
|
||||
* metadata could not be read, so its concrete kind cannot be claimed.
|
||||
*/
|
||||
export type ReferenceSourceExclusionKind = ReferenceSourceEntryKind | "unknown";
|
||||
|
||||
export interface ReferenceSourceReaderOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly shouldExclude?: (
|
||||
path: string,
|
||||
kind: ReferenceSourceExclusionKind,
|
||||
kind: ReferenceSourceEntryKind,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
@@ -90,7 +84,7 @@ export type TraversalState = {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly shouldExclude?: (
|
||||
path: string,
|
||||
kind: ReferenceSourceExclusionKind,
|
||||
kind: ReferenceSourceEntryKind,
|
||||
) => boolean;
|
||||
readonly entries: ReferenceSourceEntry[];
|
||||
readonly pending: PendingDirectory[];
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
|
||||
import {
|
||||
ARTIFACT_TOOL_CONTRACTS,
|
||||
artifactInspectionInputSchema,
|
||||
} from "../contracts/artifactToolContracts.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Operations whose MCP delivery may be a retained summary view. */
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set([
|
||||
"inspect_artifact",
|
||||
"decode_interface_builder",
|
||||
]);
|
||||
/** inspect_artifact keeps an explicit detail in its Evidence parameters. */
|
||||
const decodeWithoutDelivery = withoutDeliveryDetail(
|
||||
new Set(["decode_interface_builder"]),
|
||||
);
|
||||
|
||||
/** Register deterministic artifact inventory and safe extraction operations. */
|
||||
export const registerArtifactTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: (operation, parameters) => {
|
||||
if (operation === "inspect_artifact") {
|
||||
const { detail: _detail, ...execution } =
|
||||
@@ -42,16 +39,9 @@ export const registerArtifactTools = (
|
||||
}
|
||||
return operation === "extract_artifact"
|
||||
? { ...parameters, output_root: createArtifactExtractionDestination() }
|
||||
: parameters;
|
||||
: decodeWithoutDelivery(operation, parameters);
|
||||
},
|
||||
preflight: (operation, parameters) =>
|
||||
operation === "inspect_artifact" && parameters.detail === "summary"
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null),
|
||||
projectEvidence: (operation, parameters, evidence) =>
|
||||
operation === "inspect_artifact" && parameters.detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
parametersForEvidence: decodeWithoutDelivery,
|
||||
sourceEvidence: (operation, result) =>
|
||||
operation === "inspect_artifact"
|
||||
? artifactInspectionResultSchema
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
@@ -8,24 +6,17 @@ import {
|
||||
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
|
||||
import { runManagedProviderExecution } from "../composition/directAnalysis.js";
|
||||
import { isManagedToolName } from "../contracts/managed/managedToolContracts.js";
|
||||
|
||||
/** Register execution-free managed PE/CLI inspection. */
|
||||
export const registerManagedTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
let selectedManagedPath: string | undefined;
|
||||
const analysisForAdmission = (
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
|
||||
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Delivery detail selects a response shape; it never reaches the tools or Evidence identity. */
|
||||
const withoutDelivery = (
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): Readonly<Record<string, JsonValue>> => {
|
||||
if (operation !== "inspect_macho") return parameters;
|
||||
const { detail: _detail, ...selected } = parameters;
|
||||
return selected;
|
||||
};
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set(["inspect_macho"]);
|
||||
|
||||
/** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */
|
||||
export const registerNativeTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY);
|
||||
registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: withoutDelivery,
|
||||
parametersForEvidence: withoutDelivery,
|
||||
preflight: (operation, parameters) =>
|
||||
operation === "inspect_macho" && parameters.detail === "summary"
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null),
|
||||
projectEvidence: (operation, parameters, evidence) =>
|
||||
operation === "inspect_macho" && parameters.detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { analysisDetailSchema } from "../contracts/analysisDetail.js";
|
||||
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
|
||||
/** Registration inputs shared by provider-backed Evidence tool families. */
|
||||
export interface EvidenceToolFamilyOptions {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: WithAdmittedAnalysis;
|
||||
}
|
||||
|
||||
type Parameters = Readonly<Record<string, JsonValue>>;
|
||||
|
||||
/**
|
||||
* Summary delivery for the named operations: refuse it before analysis when
|
||||
* this server retains no Evidence, then return the retained summary view.
|
||||
*/
|
||||
export const retainedSummaryDelivery = (
|
||||
operations: ReadonlySet<string>,
|
||||
options: Pick<EvidenceToolFamilyOptions, "recordEvidence" | "evidenceById">,
|
||||
) => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
const selected = (operation: string, parameters: Parameters): boolean =>
|
||||
operations.has(operation) && parameters.detail === "summary";
|
||||
return {
|
||||
preflight: (operation: string, parameters: Parameters) => {
|
||||
if (!operations.has(operation)) return ok(null);
|
||||
const detail = analysisDetailSchema
|
||||
.unwrap()
|
||||
.optional()
|
||||
.safeParse(parameters.detail);
|
||||
if (!detail.success)
|
||||
return err(
|
||||
new AnalysisInputError(operation, { cause: detail.error }, [
|
||||
{
|
||||
path: ["detail"],
|
||||
reason: "invalid_value",
|
||||
expected: ["complete", "summary"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
return detail.data === "summary"
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null);
|
||||
},
|
||||
projectEvidence: (
|
||||
operation: string,
|
||||
parameters: Parameters,
|
||||
evidence: Evidence,
|
||||
) =>
|
||||
selected(operation, parameters)
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
};
|
||||
};
|
||||
|
||||
/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */
|
||||
export const withoutDeliveryDetail =
|
||||
(operations: ReadonlySet<string>) =>
|
||||
(operation: string, parameters: Parameters): Parameters => {
|
||||
if (!operations.has(operation)) return parameters;
|
||||
const { detail: _detail, ...selected } = parameters;
|
||||
return selected;
|
||||
};
|
||||
@@ -133,88 +133,3 @@ describe("reference import native entry failure details", () => {
|
||||
).toMatchObject({ kind: "file", content_state: "hashed", limitations: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("reference import paths beyond the platform limit", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"reports an entry beyond the absolute path limit as an I/O failure",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-import-deep-path-");
|
||||
await writeFile(join(root, "kept.txt"), "k");
|
||||
// Relative creation lets the tree exceed PATH_MAX (1024 on macOS, 4096
|
||||
// on Linux) while each 200-byte component stays below NAME_MAX.
|
||||
const deepTree = (action: "create" | "remove") =>
|
||||
promisify(execFile)(process.execPath, [
|
||||
"-e",
|
||||
`const fs = require("node:fs");
|
||||
const names = Array.from({ length: 24 }, (_, i) => "d" + String(i).padStart(2, "0") + "x".repeat(197));
|
||||
process.chdir(${JSON.stringify(root)});
|
||||
if (${JSON.stringify(action)} === "create") {
|
||||
for (const name of names) { fs.mkdirSync(name); process.chdir(name); }
|
||||
fs.writeFileSync("deep.txt", "d");
|
||||
} else {
|
||||
for (const name of names) process.chdir(name);
|
||||
fs.rmSync("deep.txt");
|
||||
for (const name of [...names].reverse()) { process.chdir(".."); fs.rmdirSync(name); }
|
||||
}`,
|
||||
]);
|
||||
await deepTree("create");
|
||||
try {
|
||||
const read = await readReferenceSource(root);
|
||||
if (!read.ok) throw new Error(read.error.message);
|
||||
expect(read.value.entries).toContainEqual(
|
||||
expect.objectContaining({ path: "kept.txt", status: "read" }),
|
||||
);
|
||||
const failed = read.value.entries.find(
|
||||
(entry) => entry.status === "failed",
|
||||
);
|
||||
expect(failed).toMatchObject({
|
||||
status: "failed",
|
||||
code: "io",
|
||||
message: expect.stringContaining("ENAMETOOLONG"),
|
||||
});
|
||||
const filteredRead = await readReferenceSource(root, {
|
||||
shouldExclude: (path, kind) =>
|
||||
path === failed?.path && kind === "directory",
|
||||
});
|
||||
if (!filteredRead.ok) throw new Error(filteredRead.error.message);
|
||||
expect(
|
||||
filteredRead.value.entries.find(
|
||||
(entry) => entry.path === failed?.path,
|
||||
),
|
||||
).toMatchObject({ status: "failed", kind: "unknown", code: "io" });
|
||||
const imported = await importTree(root);
|
||||
if (!imported.ok) throw new Error(imported.error.message);
|
||||
expect(imported.value.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "kept.txt",
|
||||
content_state: "hashed",
|
||||
}),
|
||||
);
|
||||
const deep = imported.value.entries.find(
|
||||
(entry) => entry.path === failed?.path,
|
||||
);
|
||||
expect(deep?.limitations.join(" ")).toContain(
|
||||
failed?.status === "failed" ? failed.message : "missing failure",
|
||||
);
|
||||
// A directory-only secret rule still applies when the kind is unknown.
|
||||
const secretPattern = `${failed?.path ?? "missing"}/`;
|
||||
const secret = await importReferenceSource({
|
||||
root,
|
||||
caller: "native-entry-failure-detail-test",
|
||||
policy: { secretPatterns: [secretPattern] },
|
||||
});
|
||||
if (!secret.ok) throw new Error(secret.error.message);
|
||||
expect(
|
||||
secret.value.entries.some(({ path }) => path === failed?.path),
|
||||
).toBe(false);
|
||||
expect(secret.value.exclusions).toContainEqual({
|
||||
path: failed?.path,
|
||||
reason: "configured-secret",
|
||||
pattern: secretPattern,
|
||||
});
|
||||
} finally {
|
||||
await deepTree("remove");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,10 +13,7 @@ import {
|
||||
} from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { skillDestinations } from "../../../src/application/SetupSkill.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import {
|
||||
runUninstall,
|
||||
systemUninstallHost,
|
||||
} from "../../../src/application/Uninstall.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
|
||||
|
||||
@@ -260,91 +257,6 @@ describe("ZCode .agents fallback preservation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZCode fallback uninstall", () => {
|
||||
const owned = { command: command[0], args: command.slice(1) };
|
||||
const writeFallback = async (home: string, servers: object) => {
|
||||
await mkdir(join(home, ".agents"), { recursive: true });
|
||||
const text = `${JSON.stringify({ ui: { keep: true }, mcpServers: servers }, null, 2)}\n`;
|
||||
await writeFile(fallbackPath(home), text);
|
||||
return text;
|
||||
};
|
||||
|
||||
it.each(["comment", "trailing-comma", "bom"])(
|
||||
"fails closed on strict fallback syntax: %s",
|
||||
async (mode) => {
|
||||
const { home } = await zcodeClient();
|
||||
const valid = await writeFallback(home, {
|
||||
rea: owned,
|
||||
other: { command: "other-server" },
|
||||
});
|
||||
const original =
|
||||
mode === "comment"
|
||||
? `// unsupported comment\n${valid}`
|
||||
: mode === "bom"
|
||||
? `\uFEFF${valid}`
|
||||
: valid.replace(/\}\s*$/u, ",}");
|
||||
await writeFile(fallbackPath(home), original);
|
||||
const result = await runUninstall(false, systemUninstallHost(home));
|
||||
expect(result.items).toContainEqual(
|
||||
expect.objectContaining({ name: "zcode", status: "failed" }),
|
||||
);
|
||||
expect(await readFile(fallbackPath(home), "utf8")).toBe(original);
|
||||
await expect(
|
||||
readFile(`${fallbackPath(home)}.rea.backup`),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it("removes the REA-owned fallback entry with the native registration", async () => {
|
||||
const { home, client } = await zcodeClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify({ mcp: { servers: { rea: owned } } }),
|
||||
);
|
||||
const original = await writeFallback(home, {
|
||||
rea: owned,
|
||||
other: { command: "other-server" },
|
||||
});
|
||||
|
||||
const result = await runUninstall(false, systemUninstallHost(home));
|
||||
|
||||
expect(result.status).toBe("complete");
|
||||
expect(result.items.find(({ name }) => name === "zcode")?.status).toBe(
|
||||
"removed",
|
||||
);
|
||||
const native = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(native.mcp.servers).toEqual({});
|
||||
const fallback = JSON.parse(await readFile(fallbackPath(home), "utf8"));
|
||||
expect(fallback).toEqual({
|
||||
ui: { keep: true },
|
||||
mcpServers: { other: { command: "other-server" } },
|
||||
});
|
||||
expect(await readFile(`${fallbackPath(home)}.rea.backup`, "utf8")).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a fallback-only REA entry and keeps a foreign rea entry", async () => {
|
||||
for (const [servers, expected] of [
|
||||
[{ rea: owned }, {}],
|
||||
[
|
||||
{ rea: { command: "someone-else" } },
|
||||
{ rea: { command: "someone-else" } },
|
||||
],
|
||||
] as const) {
|
||||
const { home } = await zcodeClient();
|
||||
await writeFallback(home, servers);
|
||||
|
||||
expect(
|
||||
(await runUninstall(false, systemUninstallHost(home))).status,
|
||||
).toBe("complete");
|
||||
expect(
|
||||
JSON.parse(await readFile(fallbackPath(home), "utf8")).mcpServers,
|
||||
).toEqual(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZCode fallback admission", () => {
|
||||
it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])(
|
||||
"refuses malformed fallback REA environment %j before native writes",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
z.object({
|
||||
evidence_id: z.string(),
|
||||
parameters: z.record(z.string(), z.unknown()),
|
||||
normalized_result: schema,
|
||||
});
|
||||
const summarySchema = z.object({
|
||||
kind: z.literal("summary"),
|
||||
parent_evidence_id: z.string(),
|
||||
summary: z.object({
|
||||
documents: z.array(z.unknown()),
|
||||
available_counts: z.object({
|
||||
nodes: z.number(),
|
||||
edges: z.number(),
|
||||
coverage: z.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const pageSchema = z.object({
|
||||
kind: z.literal("interface-builder"),
|
||||
items: z.array(z.unknown()),
|
||||
coverage: z.object({ next_offset: z.number().nullable() }),
|
||||
});
|
||||
|
||||
/** A two-document app; each nib is a real rooted UIKit outlet archive. */
|
||||
const fixtureApp = async (): Promise<string> => {
|
||||
const app = join(
|
||||
await createTestTempDirectory("rea-ib-summary-mcp-"),
|
||||
"Fixture.app",
|
||||
);
|
||||
const contents = join(app, "Contents");
|
||||
const resources = join(contents, "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
await mkdir(join(contents, "MacOS"));
|
||||
await writeFile(
|
||||
join(contents, "MacOS", "Fixture"),
|
||||
thinMach(0xfeedfacf, 0x0100000c),
|
||||
);
|
||||
await writeFile(
|
||||
join(contents, "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
|
||||
);
|
||||
const nib = await readFile(
|
||||
join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"),
|
||||
);
|
||||
await writeFile(join(resources, "Main.nib"), nib);
|
||||
await writeFile(join(resources, "Second.nib"), nib);
|
||||
return app;
|
||||
};
|
||||
|
||||
mcpTest(
|
||||
"retains complete Interface Builder Evidence and pages one document's rows",
|
||||
async ({ mcp, onTestFinished }) => {
|
||||
const app = await fixtureApp();
|
||||
const client = await openProductionTarget(mcp, onTestFinished, app);
|
||||
|
||||
const invalidDetail = await client.callTool({
|
||||
name: "decode_interface_builder",
|
||||
arguments: { detail: "brief" },
|
||||
});
|
||||
expect(invalidDetail.isError).toBe(true);
|
||||
expect(invalidDetail.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringMatching(/Input validation error:.*detail/su),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Summary first: its own retained parent must serve every later page.
|
||||
const summarized = await client.callTool({
|
||||
name: "decode_interface_builder",
|
||||
arguments: { detail: "summary" },
|
||||
});
|
||||
expect(summarized.isError, JSON.stringify(summarized.content)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const summary = evidenceOf(summarySchema).parse(
|
||||
summarized.structuredContent,
|
||||
).normalized_result;
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
};
|
||||
const readAll = async (view: Record<string, unknown>) => {
|
||||
const rows: unknown[] = [];
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
const paged = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: { kind: "interface-builder", ...view, offset, limit: 2 },
|
||||
},
|
||||
});
|
||||
expect(paged.isError, JSON.stringify(paged.content)).not.toBe(true);
|
||||
const page = evidenceOf(pageSchema).parse(
|
||||
paged.structuredContent,
|
||||
).normalized_result;
|
||||
rows.push(...page.items);
|
||||
offset = page.coverage.next_offset;
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
const main = "Contents/Resources/Main.nib";
|
||||
const edges = await readAll({ collection: "edges" });
|
||||
const mainNodes = await readAll({ collection: "nodes", document: main });
|
||||
|
||||
// Only then compare with a complete call made for reference.
|
||||
const complete = await client.callTool({
|
||||
name: "decode_interface_builder",
|
||||
arguments: {},
|
||||
});
|
||||
expect(complete.isError, JSON.stringify(complete.content)).not.toBe(true);
|
||||
const full = evidenceOf(interfaceBuilderAnalysisSchema).parse(
|
||||
complete.structuredContent,
|
||||
);
|
||||
expect(full.parameters).not.toHaveProperty("detail");
|
||||
const report = full.normalized_result;
|
||||
expect(report.documents).toHaveLength(2);
|
||||
// Delivery detail never reaches the decoder or the source Evidence ID.
|
||||
expect(summary.parent_evidence_id).toBe(full.evidence_id);
|
||||
expect(summary.summary.documents).toEqual(report.documents);
|
||||
expect(summary.summary.available_counts).toEqual({
|
||||
nodes: report.graph.nodes.length,
|
||||
edges: report.graph.edges.length,
|
||||
coverage: report.graph.coverage.length,
|
||||
});
|
||||
expect(edges).toEqual(report.graph.edges);
|
||||
const expectedMainNodes = report.graph.nodes.filter(({ evidence }) =>
|
||||
evidence.some(({ artifact_path }) => artifact_path === main),
|
||||
);
|
||||
expect(expectedMainNodes.length).toBeGreaterThan(0);
|
||||
expect(expectedMainNodes.length).toBeLessThan(report.graph.nodes.length);
|
||||
expect(mainNodes).toEqual(expectedMainNodes);
|
||||
|
||||
const unknownDocument = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: {
|
||||
kind: "interface-builder",
|
||||
collection: "nodes",
|
||||
document: "Contents/Resources/Missing.nib",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(unknownDocument)).toMatchObject({
|
||||
error: { code: "invalid_request" },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,40 +1,24 @@
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
archiveNumberMetadataCases,
|
||||
expectKeyedArchiveNumberMetadata,
|
||||
keyedArchiveNumberMetadataFixture,
|
||||
} from "../../fixtures/keyedArchiveNumberMetadata.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
mcpTest.for(archiveNumberMetadataCases)(
|
||||
"MCP preserves numeric meaning for $name",
|
||||
async (item, { mcp, onTestFinished }) => {
|
||||
const fixture = await keyedArchiveNumberMetadataFixture(item);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
const client = await openProductionTarget(
|
||||
mcp,
|
||||
onTestFinished,
|
||||
fixture.path,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: fixture.path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_keyed_archive",
|
||||
arguments: {},
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
expectKeyedArchivePrototypeKeys,
|
||||
keyedArchivePrototypeKeyFixture,
|
||||
} from "../../fixtures/keyedArchivePrototypeKeys.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
mcpTest(
|
||||
"MCP retains ordinary evidence with formatted XML prototype keys",
|
||||
async ({ mcp, onTestFinished }) => {
|
||||
const fixture = await keyedArchivePrototypeKeyFixture();
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
const client = await openProductionTarget(
|
||||
mcp,
|
||||
onTestFinished,
|
||||
fixture.path,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: fixture.path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_keyed_archive",
|
||||
arguments: {},
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const demangledSchema = z.object({
|
||||
normalized_result: z.object({
|
||||
@@ -30,24 +27,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
"tool",
|
||||
);
|
||||
await copyFile("/usr/bin/true", target);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const client = await openProductionTarget(mcp, onTestFinished, target);
|
||||
|
||||
const called = await client.callTool({
|
||||
name: "demangle_swift",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { copyFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
@@ -16,6 +13,7 @@ import { ok } from "../../../src/domain/result.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
z.object({
|
||||
@@ -54,24 +52,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
"ls",
|
||||
);
|
||||
await copyFile("/bin/ls", target);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const client = await openProductionTarget(mcp, onTestFinished, target);
|
||||
|
||||
const complete = await client.callTool({
|
||||
name: "inspect_macho",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import { expect, type OnTestFinishedHandler } from "vitest";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import type { TestMcp } from "./mcpFixture.js";
|
||||
|
||||
/** Open one real target in a production session served over MCP. */
|
||||
export const openProductionTarget = async (
|
||||
mcp: TestMcp,
|
||||
onTestFinished: (handler: OnTestFinishedHandler) => void,
|
||||
path: string,
|
||||
): Promise<Client> => {
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(createServer({ kind: "session", session }));
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
return client;
|
||||
};
|
||||
Reference in New Issue
Block a user