Compare commits

..
Author SHA1 Message Date
Kaoru0822 f3cd103b31 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-14 00:31:27 +00:00
morluto 94e360c11b test(artifacts): assert SDK argument rejection at its boundary 2026-10-14 02:40:06 +08:00
morluto 7b9ab778a1 fix(mcp): reject invalid delivery selections through typed preflight 2026-10-14 02:40:06 +08:00
morluto a92b960f6f fix(mcp): validate delivery detail before removing it 2026-10-14 02:40:06 +08:00
da49ea2692 fix(artifacts): describe incomplete Interface Builder graphs accurately
graph.truncated is also set for invalid archives, omitted dictionary keys
and fixed decode budgets, not only caller-selected limits, so the view's
unknown now points at coverage and limitations for the cause. The MCP
test requests the summary first and reads its retained pages before any
complete call, covering the flow where complete delivery cannot fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:40:06 +08:00
f993d33028 perf(artifacts): add opt-in Interface Builder summary delivery and pages
decode_interface_builder returns the complete object, hierarchy and
connection graph inline. XLD.app's graph is 6.7 MB (about 1.98M o200k
tokens) and Transmission.app's 6.3 MB; over MCP the doubled text and
structured content exceed the default response budget, so an agent got
a resource_constraint error instead of any answer.

Add the established opt-in delivery: omitted or complete detail keeps the
complete result and its Evidence ID, while summary retains the complete
Evidence and returns every decoded document with node-kind,
edge-relation and coverage-status tallies (10k tokens for XLD).
inspect_analysis_view kind interface-builder pages documents, nodes,
edges or coverage; nodes and edges accept an exact decoded document path,
matched against each row's recorded evidence, and an undecoded path is
refused. Decoder truncation stays visible on every page.

Related cleanup, since the new code repeated it a ninth time:
- parseAnalysisViewResult owns parsing a view parent's normalized result
  with the operation's schema and mapping issues into the view source.
- retainedSummaryDelivery owns the preflight/summary hook pair and the
  shared registration options for artifact, native and managed tools.
- openProductionTarget owns the production MCP session test setup.
Duplication drops from 3.02% to 2.98% (threshold 3.0%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:40:06 +08:00
44 changed files with 912 additions and 765 deletions
@@ -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
+1 -5
View File
@@ -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
-144
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -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.
+23 -39
View File
@@ -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);
+8 -22
View File
@@ -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",
-1
View File
@@ -47,7 +47,6 @@ export interface SetupClient {
| "pi"
| "kimi"
| "hermes"
| "zcode_fallback"
| "zcode"
| "dsh"
| "unsupported";
+3 -12
View File
@@ -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
+7 -1
View File
@@ -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(
+7 -1
View File
@@ -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),
+31 -1
View File
@@ -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 },
),
);
};
+4 -15
View File
@@ -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,
+4 -15
View File
@@ -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 = {
+4 -15
View File
@@ -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),
});
};
+4 -15
View File
@@ -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. */
+6 -16
View File
@@ -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,
+4 -15
View File
@@ -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 ||
-24
View File
@@ -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";
+1 -11
View File
@@ -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,
-20
View File
@@ -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(
+1 -3
View File
@@ -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));
+3 -10
View File
@@ -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;
+2 -8
View File
@@ -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[];
+19 -29
View File
@@ -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
+2 -11
View File
@@ -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 = (
+9 -38
View File
@@ -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),
});
};
+81
View File
@@ -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: {},
+2 -22
View File
@@ -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",
+33
View File
@@ -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;
};