Add a read-only app resource session shared by CLI and MCP. Preserve resource-root provenance without inventing executable identity, reject binary-only snapshots and operations, and retain failed cleanup owners across switches and retries. Keep filesystem admission failures typed and preserve cancellation ordering from observed cleanup facts. Document resource routing in the packaged investigation skill. BREAKING CHANGE: resource-only observations may have a null Evidence subject and null executable target digests. Clients must use resource_root and reported per-resource provenance instead. Refs #5589
235 lines
7.3 KiB
TypeScript
235 lines
7.3 KiB
TypeScript
import { build } from "plist";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect } from "vitest";
|
|
|
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
|
|
const resourceOperations = [
|
|
"inspect_plist",
|
|
"decode_interface_builder",
|
|
"inspect_asset_catalog",
|
|
"inspect_keyed_archive",
|
|
] as const;
|
|
|
|
cliTest.skipIf(process.platform !== "darwin")(
|
|
"inspects a keyless app resource root through the compiled CLI",
|
|
async ({ cli }) => {
|
|
const temporaryRoot = await createTestTempDirectory(
|
|
"rea-bundle-resource-acceptance-",
|
|
);
|
|
const firstBundle = await createKeylessBundle(temporaryRoot, "First");
|
|
|
|
for (const scenario of cliScenarios(firstBundle)) {
|
|
const response = await cli.run({
|
|
arguments: [...scenario.arguments, "--json"],
|
|
environment: { REA_LOG_LEVEL: "silent" },
|
|
});
|
|
expect(
|
|
response.exitCode,
|
|
`${scenario.operation}: ${response.stdout} ${response.stderr}`,
|
|
).toBe(0);
|
|
const evidence = expectResourceEvidence(
|
|
response.json,
|
|
scenario.operation,
|
|
firstBundle,
|
|
);
|
|
if (scenario.operation === "inspect_keyed_archive")
|
|
expect(evidence.normalized_result).toMatchObject({
|
|
archive_path: "Resources/Archive.plist",
|
|
objects: expect.arrayContaining([
|
|
expect.objectContaining({ value: "resource-only-value" }),
|
|
]),
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
cliTest.skipIf(process.platform !== "darwin")(
|
|
"retains and switches keyless resource sessions through stdio MCP",
|
|
async () => {
|
|
const temporaryRoot = await createTestTempDirectory(
|
|
"rea-resource-session-acceptance-",
|
|
);
|
|
const firstBundle = await createKeylessBundle(temporaryRoot, "First");
|
|
const secondBundle = await createKeylessBundle(temporaryRoot, "Second");
|
|
const { call } = await connectLocalToolsMcp();
|
|
const opened = await call("open_bundle_resources", { path: firstBundle });
|
|
expect(opened.isError).not.toBe(true);
|
|
expect(
|
|
toolContract("open_bundle_resources").outputSchema.parse(
|
|
opened.structuredContent,
|
|
),
|
|
).toMatchObject({
|
|
result: {
|
|
caller_path: firstBundle,
|
|
canonical_path: firstBundle,
|
|
executable_sha256: null,
|
|
},
|
|
});
|
|
|
|
const firstStatus = await call("binary_session", {});
|
|
expect(firstStatus.isError).not.toBe(true);
|
|
expect(
|
|
toolContract("binary_session").outputSchema.parse(
|
|
firstStatus.structuredContent,
|
|
),
|
|
).toMatchObject({
|
|
result: {
|
|
open: true,
|
|
session_kind: "bundle_resources",
|
|
analysis_provider_binding: null,
|
|
analysis_provider_candidates: [],
|
|
resource_root: {
|
|
canonical_path: firstBundle,
|
|
executable_sha256: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
for (const operation of resourceOperations) {
|
|
const arguments_ =
|
|
operation === "inspect_keyed_archive"
|
|
? { path: "Resources/Archive.plist" }
|
|
: {};
|
|
const response = await call(operation, arguments_);
|
|
expect(response.isError, operation).not.toBe(true);
|
|
const evidence = expectResourceEvidence(
|
|
response.structuredContent,
|
|
operation,
|
|
firstBundle,
|
|
);
|
|
if (operation === "inspect_keyed_archive")
|
|
expect(evidence.normalized_result).toMatchObject({
|
|
archive_path: "Resources/Archive.plist",
|
|
objects: expect.arrayContaining([
|
|
expect.objectContaining({ value: "resource-only-value" }),
|
|
]),
|
|
});
|
|
}
|
|
|
|
const retained = await call("get_evidence_bundle", {});
|
|
expect(retained.isError).not.toBe(true);
|
|
const evidenceBundle = toolContract(
|
|
"get_evidence_bundle",
|
|
).outputSchema.parse(retained.structuredContent);
|
|
for (const operation of resourceOperations) {
|
|
const record = evidenceBundle.result.records.find(
|
|
(record) => record.operation === operation,
|
|
);
|
|
expectResourceEvidence(record, operation, firstBundle);
|
|
}
|
|
|
|
const switched = await call("open_bundle_resources", {
|
|
path: secondBundle,
|
|
});
|
|
expect(switched.isError).not.toBe(true);
|
|
expect(
|
|
toolContract("open_bundle_resources").outputSchema.parse(
|
|
switched.structuredContent,
|
|
),
|
|
).toMatchObject({ result: { canonical_path: secondBundle } });
|
|
const switchedStatus = await call("binary_session", {});
|
|
expect(switchedStatus.isError).not.toBe(true);
|
|
expect(
|
|
toolContract("binary_session").outputSchema.parse(
|
|
switchedStatus.structuredContent,
|
|
),
|
|
).toMatchObject({
|
|
result: {
|
|
open: true,
|
|
session_kind: "bundle_resources",
|
|
resource_root: {
|
|
caller_path: secondBundle,
|
|
canonical_path: secondBundle,
|
|
executable_sha256: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const closed = await call("close_binary", {});
|
|
expect(closed.isError).not.toBe(true);
|
|
toolContract("close_binary").outputSchema.parse(closed.structuredContent);
|
|
const finalStatus = await call("binary_session", {});
|
|
expect(finalStatus.isError).not.toBe(true);
|
|
expect(
|
|
toolContract("binary_session").outputSchema.parse(
|
|
finalStatus.structuredContent,
|
|
),
|
|
).toMatchObject({ result: { open: false, cleanup_pending: false } });
|
|
},
|
|
);
|
|
|
|
const createKeylessBundle = async (
|
|
root: string,
|
|
name: string,
|
|
): Promise<string> => {
|
|
const bundle = join(root, `${name}.app`);
|
|
await mkdir(join(bundle, "Resources"), { recursive: true });
|
|
await mkdir(join(bundle, "Assets.xcassets"));
|
|
await writeFile(
|
|
join(bundle, "Info.plist"),
|
|
build({ CFBundleIdentifier: `org.example.${name.toLowerCase()}` }),
|
|
);
|
|
await writeFile(
|
|
join(bundle, "Resources", "Archive.plist"),
|
|
build({
|
|
$archiver: "NSKeyedArchiver",
|
|
$version: 100000,
|
|
$objects: ["$null", "resource-only-value"],
|
|
$top: { root: { UID: 1 } },
|
|
}),
|
|
);
|
|
return bundle;
|
|
};
|
|
|
|
const cliScenarios = (bundle: string) =>
|
|
[
|
|
{ operation: "inspect_plist", arguments: ["inspect-plist", bundle] },
|
|
{
|
|
operation: "decode_interface_builder",
|
|
arguments: ["decode-interface-builder", bundle],
|
|
},
|
|
{
|
|
operation: "inspect_asset_catalog",
|
|
arguments: ["inspect-asset-catalog", bundle],
|
|
},
|
|
{
|
|
operation: "inspect_keyed_archive",
|
|
arguments: ["inspect-keyed-archive", bundle, "Resources/Archive.plist"],
|
|
},
|
|
] as const;
|
|
|
|
const expectResourceEvidence = (
|
|
value: unknown,
|
|
operation: (typeof resourceOperations)[number],
|
|
bundle: string,
|
|
) => {
|
|
const evidence = parseEvidence(
|
|
toolContract(operation).outputSchema.parse(value),
|
|
);
|
|
expect(evidence).toMatchObject({
|
|
operation,
|
|
subject: null,
|
|
normalized_result: {
|
|
resource_root: {
|
|
caller_path: bundle,
|
|
canonical_path: bundle,
|
|
executable_sha256: null,
|
|
},
|
|
},
|
|
});
|
|
if (operation === "inspect_plist")
|
|
expect(evidence.normalized_result).toMatchObject({
|
|
source_path: join(bundle, "Info.plist"),
|
|
bundle: { executable: null },
|
|
});
|
|
else
|
|
expect(evidence.normalized_result).toMatchObject({ target_sha256: null });
|
|
return evidence;
|
|
};
|