diff --git a/docs/android-analysis.md b/docs/android-analysis.md
index efcb05c64..849aeb2b1 100644
--- a/docs/android-analysis.md
+++ b/docs/android-analysis.md
@@ -126,8 +126,13 @@ also stops target hashing before provider admission.
These operations do not execute the APK or provide split APK/AAB handling,
signature validation, full resource-table semantics, native-library analysis or
-Android runtime capture. Existing artifact inventory/extraction tools can supply
-archive evidence. Project that Evidence with the execution-free inventory tool:
+Android runtime capture. An `.aab` suffix is a ZIP archive, not an APK, so
+Android tools and the application graph do not treat it as a standalone APK.
+Existing artifact inventory/extraction tools can supply archive evidence.
+Inventory keeps ZIP names that differ only in case and records that a
+case-insensitive destination cannot store both spellings. Extraction fails
+only when that destination directory cannot keep both names. Project that
+Evidence with the execution-free inventory tool:
```sh
rea project-android-application-graph '{"inventory_evidence":[]}'
diff --git a/docs/installation.md b/docs/installation.md
index 38ea09f5a..acc285e69 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -135,10 +135,17 @@ listed after the table because its connector is not one of these files:
| Antigravity | `antigravity` |
| GitHub Copilot CLI | `copilot_cli` |
| Command Code | `commandcode` |
+| Qwen Code | `qwen_code` |
| VS Code | `vscode` |
| Grok Build | `grok_build` |
| OMP | `omp` |
+For Qwen Code, setup registers `rea` in the `mcpServers` table of the
+user-level `~/.qwen/settings.json`, or `$QWEN_HOME/settings.json` when configured.
+Setup and doctor follow Qwen Code's tilde and working-directory-relative home
+overrides. Qwen Code also discovers personal skills in `~/.agents/skills`,
+where setup installs the bundled workflow.
+
For OMP, setup writes a `type: "stdio"` entry to the user-level
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR`, an absolute
`PI_CODING_AGENT_DIR`, and the profile selected by `OMP_PROFILE` or
@@ -194,10 +201,10 @@ save verified paths for an existing Ghidra installation.
The bundled skill is installed where each selected client discovers personal
skills: Claude Code uses `~/.claude/skills` (or
-`$CLAUDE_CONFIG_DIR/skills` when configured), while other supported clients
-use the shared `~/.agents/skills` directory. A mixed selection plans both
-paths. Selecting the skill without a client uses the shared directory. Setup
-leaves existing skill copies in other locations untouched.
+`$CLAUDE_CONFIG_DIR/skills` when configured), while other supported clients,
+including Qwen Code, use the shared `~/.agents/skills` directory. A mixed selection
+plans the relevant paths. Selecting the skill without a client uses the shared
+directory. Setup leaves existing skill copies in other locations untouched.
`doctor --skill --json` verifies the selected copies against the bundled
instructions and references. Consumers should use `identity.skill.state`,
diff --git a/docs/process-capture.md b/docs/process-capture.md
index c315e3e9e..5cc246ec2 100644
--- a/docs/process-capture.md
+++ b/docs/process-capture.md
@@ -110,11 +110,14 @@ while comparisons and trace assertions use the normalized text. Retaining the
original adds at most `limits.output_bytes` of text, because it comes from the
same admitted PTY chunks rather than a second unbounded stream.
-Rendered frame `lines` hold the visible rows after normalization, without
-trailing spaces; pad a line to the frame's `columns` to restore the fixed-width
-row. `serialized_state` keeps the exact terminal state. Captures commit this
-line format in their comparison contract, so a capture whose lines were padded
-to full width does not compare as the same contract.
+Rendered frame `lines` hold the visible rows after normalization, with trailing
+U+0020 spaces removed. Blank rows and internal spaces remain. `columns` records
+terminal cell width; normalization and Unicode can make string lengths differ
+from that width. `serialized_state` holds terminal serialization after the
+selected normalization. Original admitted PTY chunks are available through
+`raw_data ?? data`, as described above. Captures commit this line format in their
+comparison contract, so a capture whose lines were padded to full width does not
+compare as the same contract.
Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and
`listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with
diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs
index f9ee010af..1a97cfbab 100644
--- a/scripts/verify-package-environment.mjs
+++ b/scripts/verify-package-environment.mjs
@@ -85,6 +85,7 @@ export async function verifyPackageEnvironment({
PI_CODING_AGENT_DIR: undefined,
PI_CONFIG_DIR: undefined,
PI_PROFILE: undefined,
+ QWEN_HOME: undefined,
OPENCODE_CONFIG: undefined,
XDG_CONFIG_HOME: join(home, ".config"),
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts
index a657f1df4..1f5a12473 100644
--- a/src/application/ClientRegistrationStatus.ts
+++ b/src/application/ClientRegistrationStatus.ts
@@ -91,6 +91,7 @@ export const readClientRegistrationStatuses = async (
PI_CODING_AGENT_DIR: options.environment.PI_CODING_AGENT_DIR,
PI_CONFIG_DIR: options.environment.PI_CONFIG_DIR,
PI_PROFILE: options.environment.PI_PROFILE,
+ QWEN_HOME: options.environment.QWEN_HOME,
SAND_DATA_ROOT: options.environment.SAND_DATA_ROOT,
XDG_CONFIG_HOME: options.environment.XDG_CONFIG_HOME,
},
diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts
index 97467a227..52f5577ca 100644
--- a/src/application/SupportedClients.ts
+++ b/src/application/SupportedClients.ts
@@ -1,4 +1,4 @@
-import { isAbsolute, join } from "node:path";
+import { isAbsolute, join, resolve } from "node:path";
import { lstatSync } from "node:fs";
/** One supported client configuration location. */
@@ -35,6 +35,7 @@ interface ClientPathContext {
readonly PI_CODING_AGENT_DIR?: string | undefined;
readonly PI_CONFIG_DIR?: string | undefined;
readonly PI_PROFILE?: string | undefined;
+ readonly QWEN_HOME?: string | undefined;
readonly SAND_DATA_ROOT?: string | undefined;
readonly XDG_CONFIG_HOME?: string | undefined;
};
@@ -85,6 +86,25 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
const codexDirectory = ({ home, env }: ClientPathContext): string =>
env.CODEX_HOME ?? join(home, ".codex");
+/** Match Qwen Code's home override, including tilde and cwd-relative paths. */
+const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
+ const override = env.QWEN_HOME;
+ if (!override) return join(home, ".qwen");
+ const expanded =
+ override === "~"
+ ? home
+ : override.startsWith("~/") || override.startsWith("~\\")
+ ? join(
+ home,
+ ...override
+ .slice(2)
+ .split(/[/\\]+/)
+ .filter(Boolean),
+ )
+ : override;
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
+};
+
const grokDirectory = ({ home, env }: ClientPathContext): string =>
env.GROK_HOME ?? join(home, ".grok");
@@ -272,6 +292,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: commandCodeDirectory,
format: "commandcode",
},
+ {
+ name: "qwen_code",
+ displayName: "Qwen Code",
+ configPath: (context: ClientPathContext) =>
+ join(qwenCodeDirectory(context), "settings.json"),
+ markerPath: qwenCodeDirectory,
+ format: "json",
+ },
{
name: "vscode",
displayName: "VS Code",
@@ -323,6 +351,7 @@ export const supportedClients = (
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
PI_CONFIG_DIR: process.env.PI_CONFIG_DIR,
PI_PROFILE: process.env.PI_PROFILE,
+ QWEN_HOME: process.env.QWEN_HOME,
SAND_DATA_ROOT: process.env.SAND_DATA_ROOT,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
},
diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts
index d5ec35cb4..ce8d6c515 100644
--- a/src/artifacts/ArtifactPaths.ts
+++ b/src/artifacts/ArtifactPaths.ts
@@ -1,9 +1,24 @@
import { posix } from "node:path";
+import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u;
+/**
+ * English Unicode case folding used only to describe logical names a
+ * case-insensitive destination might collapse. It is not a filesystem case table.
+ */
+const ENGLISH_UNICODE_CASE_NOTE =
+ "This comparison is not a filesystem observation.";
+
+/** Inventory limitation when logical names collide under English Unicode case folding. */
+export const ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION = `Logical archive names collide under English (en-US) Unicode case folding; a case-insensitive destination cannot store both spellings. ${ENGLISH_UNICODE_CASE_NOTE}`;
+
+/** User-facing prefix for a destination that cannot keep two logical spellings. */
+export const DESTINATION_CASE_COLLISION_PREFIX =
+ "Destination filesystem cannot store both ";
+
/** Normalize an untrusted logical archive path without touching filesystem. */
export const normalizeArtifactPath = (input: string): string => {
if (
@@ -30,7 +45,7 @@ export const normalizeArtifactPath = (input: string): string => {
return normalized;
};
-/** Reject exact, Unicode-normalized, case-folded, and prefix collisions. */
+/** Reject exact and same-spelling prefix collisions. Case-distinct names stay distinct. */
export class ArtifactPathRegistry {
readonly #root: PathTrieNode = { kind: undefined, children: new Map() };
@@ -43,16 +58,10 @@ export class ArtifactPathRegistry {
"path",
`Artifact prefix conflict: ${path}`,
);
- const foldedPart = part.toLocaleLowerCase("en-US");
- let child = node.children.get(foldedPart);
- if (child !== undefined && child.spelling !== part)
- throw new ArtifactReaderFailure(
- "path",
- `Artifact path collision: ${path} differs only in case from ${[...parts.slice(0, index), child.spelling].join("/")}`,
- );
+ let child = node.children.get(part);
if (child === undefined) {
- child = { spelling: part, kind: undefined, children: new Map() };
- node.children.set(foldedPart, child);
+ child = { kind: undefined, children: new Map() };
+ node.children.set(part, child);
}
node = child;
if (index === parts.length - 1) {
@@ -73,7 +82,144 @@ export class ArtifactPathRegistry {
}
interface PathTrieNode {
- readonly spelling?: string;
kind: "file" | "directory" | "symlink" | "slice" | undefined;
readonly children: Map;
}
+
+interface CaseNode {
+ readonly bySpelling: Map;
+ readonly spellingsByFold: Map;
+ readonly terminals: string[];
+}
+
+const emptyCaseNode = (): CaseNode => ({
+ bySpelling: new Map(),
+ spellingsByFold: new Map(),
+ terminals: [],
+});
+
+const englishCaseFold = (part: string): string =>
+ part.toLocaleLowerCase("en-US");
+
+/**
+ * Record English (en-US) Unicode case collisions on each affected occurrence.
+ * Returns the inventory limitation when any logical names collide.
+ */
+export const annotateEnglishUnicodeCaseCollisions = (
+ occurrences: readonly {
+ readonly logical_path: string;
+ limitations: string[];
+ }[],
+): string | undefined => {
+ const collisions = englishUnicodeCaseCollisions(
+ occurrences.map(({ logical_path }) => logical_path),
+ );
+ if (collisions.size === 0) return undefined;
+ for (const occurrence of occurrences) {
+ const others = collisions.get(occurrence.logical_path);
+ if (others === undefined || others.length === 0) continue;
+ occurrence.limitations.push(
+ `Logical path ${occurrence.logical_path} collides under English (en-US) Unicode case folding with ${others.join(", ")}; a case-insensitive destination cannot store both. ${ENGLISH_UNICODE_CASE_NOTE}`,
+ );
+ }
+ return ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION;
+};
+
+/**
+ * Name the directory-listing spelling that differs from the requested segment
+ * only by English (en-US) Unicode case. Exact matches are the same logical name.
+ */
+export const destinationCaseCollisionMessage = (
+ requestedPath: string,
+ existingNames: readonly string[],
+): string | undefined => {
+ const slash = requestedPath.lastIndexOf("/");
+ const segment = slash < 0 ? requestedPath : requestedPath.slice(slash + 1);
+ if (existingNames.includes(segment)) return undefined;
+ const folded = englishCaseFold(segment);
+ const existing = existingNames
+ .filter((name) => englishCaseFold(name) === folded)
+ .sort(compareUnicodeCodePoints)[0];
+ if (existing === undefined) return undefined;
+ const parent = slash < 0 ? "" : requestedPath.slice(0, slash + 1);
+ return `${DESTINATION_CASE_COLLISION_PREFIX}${requestedPath} and ${parent}${existing}; inventory retains both logical names.`;
+};
+
+const englishUnicodeCaseCollisions = (
+ paths: readonly string[],
+): ReadonlyMap => {
+ const root = emptyCaseNode();
+ for (const path of paths) {
+ if (path.length === 0 || path === ".") continue;
+ let node = root;
+ for (const part of path.split("/")) {
+ const folded = englishCaseFold(part);
+ const spellings = node.spellingsByFold.get(folded);
+ if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
+ else if (!spellings.includes(part)) spellings.push(part);
+ let child = node.bySpelling.get(part);
+ if (child === undefined) {
+ child = emptyCaseNode();
+ node.bySpelling.set(part, child);
+ }
+ node = child;
+ }
+ node.terminals.push(path);
+ }
+ const collisions = new Map>();
+ recordCaseCollisions(root, collisions);
+ return new Map(
+ [...collisions.entries()].map(([path, others]) => [
+ path,
+ [...others].sort(compareUnicodeCodePoints),
+ ]),
+ );
+};
+
+const recordCaseCollisions = (
+ node: CaseNode,
+ collisions: Map>,
+): void => {
+ for (const spellings of node.spellingsByFold.values())
+ if (spellings.length > 1) noteFoldedSpellings(node, spellings, collisions);
+ for (const child of node.bySpelling.values())
+ recordCaseCollisions(child, collisions);
+};
+
+const noteFoldedSpellings = (
+ node: CaseNode,
+ spellings: readonly string[],
+ collisions: Map>,
+): void => {
+ const groups = spellings.map((spelling) => {
+ const child = node.bySpelling.get(spelling);
+ return child === undefined ? [] : terminalsUnder(child);
+ });
+ for (const [index, group] of groups.entries())
+ noteCaseGroup(
+ group,
+ groups.flatMap((candidate, candidateIndex) =>
+ candidateIndex === index ? [] : candidate,
+ ),
+ collisions,
+ );
+};
+
+const noteCaseGroup = (
+ group: readonly string[],
+ others: readonly string[],
+ collisions: Map>,
+): void => {
+ for (const path of group) {
+ const set = collisions.get(path) ?? new Set();
+ for (const other of others) if (other !== path) set.add(other);
+ collisions.set(path, set);
+ }
+};
+
+const terminalsUnder = (node: CaseNode): readonly string[] => {
+ const paths = [...node.terminals];
+ for (const child of node.bySpelling.values())
+ paths.push(...terminalsUnder(child));
+ return paths;
+};
diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts
index fafb6fee4..05ce0ac68 100644
--- a/src/artifacts/SafeOutputTree.ts
+++ b/src/artifacts/SafeOutputTree.ts
@@ -4,6 +4,7 @@ import {
lstat,
mkdir,
open,
+ readdir,
realpath,
rm,
type FileHandle,
@@ -14,6 +15,7 @@ import { streamChunkToBuffer } from "./StreamBytes.js";
import {
ArtifactPathRegistry,
+ destinationCaseCollisionMessage,
normalizeArtifactPath,
} from "./ArtifactPaths.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
@@ -134,7 +136,8 @@ export class SafeOutputTree {
constants.O_WRONLY |
constants.O_NOFOLLOW,
0o600,
- ).catch((cause: unknown) => {
+ ).catch(async (cause: unknown) => {
+ await throwIfDestinationCaseCollision(dirname(destination), path, cause);
throw new ArtifactReaderFailure(
"path",
`Could not exclusively create extraction path: ${path}`,
@@ -245,17 +248,28 @@ export class SafeOutputTree {
if (fileName === undefined)
throw new ArtifactReaderFailure("path", "Invalid extraction path");
let current = this.#outputRoot;
+ let logicalParent = "";
for (const part of parts) {
+ const parentDirectory = current;
+ const logicalPath =
+ logicalParent.length === 0 ? part : `${logicalParent}/${part}`;
current = join(current, part);
+ let created = true;
await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => {
if (!isAlreadyExists(cause)) throw cause;
+ created = false;
});
+ if (!created)
+ await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
const metadata = await lstat(current);
- if (!metadata.isDirectory() || metadata.isSymbolicLink())
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
+ await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
throw new ArtifactReaderFailure(
"path",
`Unsafe extraction parent: ${relativePath}`,
);
+ }
+ logicalParent = logicalPath;
}
return join(current, fileName);
}
@@ -330,6 +344,23 @@ const isNotFound = (cause: unknown): boolean =>
const isAlreadyExists = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
+/** Fail when this directory already holds another spelling of the requested segment. */
+const throwIfDestinationCaseCollision = async (
+ parentDirectory: string,
+ logicalPath: string,
+ cause?: unknown,
+): Promise => {
+ const names = await readdir(parentDirectory).catch(() => undefined);
+ if (names === undefined) return;
+ const message = destinationCaseCollisionMessage(logicalPath, names);
+ if (message === undefined) return;
+ throw new ArtifactReaderFailure(
+ "path",
+ message,
+ cause === undefined ? undefined : { cause },
+ );
+};
+
const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts
index abd524693..0719e58f4 100644
--- a/src/artifacts/inventory/scanCanonical.ts
+++ b/src/artifacts/inventory/scanCanonical.ts
@@ -73,12 +73,12 @@ export const scanCanonicalArtifactInventory = async (
try {
if (reader instanceof AsarArtifactReader)
await reader.prepareContainer(rootDigest?.sha256, options.signal);
- const { nodes, occurrences, pendingContradictions } = await scanReader(
- reader,
- ownedReaders,
- options.signal,
- integrity,
- );
+ const {
+ nodes,
+ occurrences,
+ pendingContradictions,
+ caseCollisionLimitation,
+ } = await scanReader(reader, ownedReaders, options.signal, integrity);
outcome = {
kind: "completed",
snapshot: await buildInventorySnapshot({
@@ -91,6 +91,7 @@ export const scanCanonicalArtifactInventory = async (
nodes,
occurrences,
pendingContradictions,
+ caseCollisionLimitation,
}),
};
} catch (cause: unknown) {
@@ -130,6 +131,7 @@ interface SnapshotBuildInput {
readonly nodes: Map;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
+ readonly caseCollisionLimitation: string | undefined;
}
const buildInventorySnapshot = async (
@@ -202,7 +204,12 @@ const buildInventorySnapshot = async (
edges: orderedEdges,
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
- limitations: buildLimitations(rootFormat, reader, integrityContradictions),
+ limitations: buildLimitations(
+ rootFormat,
+ reader,
+ integrityContradictions,
+ input.caseCollisionLimitation,
+ ),
};
};
@@ -310,8 +317,10 @@ const buildLimitations = (
rootFormat: ArtifactOccurrence["artifact_format"],
reader: ArtifactReader | undefined,
integrityContradictions: readonly IntegrityContradiction[],
+ caseCollisionLimitation: string | undefined,
): string[] => [
...inventoryLimitations(rootFormat, reader),
+ ...(caseCollisionLimitation === undefined ? [] : [caseCollisionLimitation]),
...(integrityContradictions.length === 0
? []
: [
diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts
index 1ee691a74..89b1b628e 100644
--- a/src/artifacts/inventory/scanReader.ts
+++ b/src/artifacts/inventory/scanReader.ts
@@ -1,6 +1,7 @@
import { isAbsolute } from "node:path";
import {
+ annotateEnglishUnicodeCaseCollisions,
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../ArtifactPaths.js";
@@ -64,10 +65,16 @@ export const scanReader = async (
readonly nodes: Map;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
+ readonly caseCollisionLimitation: string | undefined;
}> => {
const { nodes, occurrences, pendingContradictions } = emptyScan();
if (reader === undefined)
- return { nodes, occurrences, pendingContradictions };
+ return {
+ nodes,
+ occurrences,
+ pendingContradictions,
+ caseCollisionLimitation: undefined,
+ };
const context: ScanContext = {
reader,
signal,
@@ -90,7 +97,12 @@ export const scanReader = async (
context.occurrenceByPath,
context.expandedContainerIds,
)?.occurrence_id ?? null;
- return { nodes, occurrences, pendingContradictions };
+ return {
+ nodes,
+ occurrences,
+ pendingContradictions,
+ caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
+ };
};
const visitArtifactEntries = async (
diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts
index a6926a19f..744ea34c3 100644
--- a/src/contracts/artifactToolContracts.ts
+++ b/src/contracts/artifactToolContracts.ts
@@ -63,7 +63,7 @@ const artifact = <
export const ARTIFACT_TOOL_CONTRACTS = [
artifact(
"inspect_artifact",
- "Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph, source Evidence, observations, relationships, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize its regular contents.",
+ "Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph once, as the nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize its regular contents.",
artifactInventoryInputSchema,
),
artifact(
diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts
index 8b1bd5b9e..3b83dc4a7 100644
--- a/src/contracts/inputSchemaPresentation.ts
+++ b/src/contracts/inputSchemaPresentation.ts
@@ -59,7 +59,7 @@ const referencedDescription = (
const describeSchema = (
schema: unknown,
root: Readonly>,
- describeProperty: (property: string) => string,
+ describeProperty: (property: string) => string | undefined,
): unknown => {
if (!isObject(schema)) return schema;
return Object.fromEntries(
@@ -70,16 +70,16 @@ const describeSchema = (
Object.fromEntries(
Object.entries(value).map(([name, child]) => {
const described = describeSchema(child, root, describeProperty);
+ if (key !== "properties" || !isObject(described))
+ return [name, described];
+ const description =
+ referencedDescription(root, described) ??
+ describeProperty(name);
return [
name,
- key === "properties" && isObject(described)
- ? {
- ...described,
- description:
- referencedDescription(root, described) ??
- describeProperty(name),
- }
- : described,
+ description === undefined
+ ? described
+ : { ...described, description },
];
}),
),
@@ -223,7 +223,7 @@ const flattenRootObjectUnion = (
/** Describe canonical input fields and present the root as one object. */
export const presentInputJsonSchema = (
root: Record,
- describeProperty: (property: string) => string,
+ describeProperty: (property: string) => string | undefined,
): Record => {
const flattened = flattenRootObjectUnion(root);
const described = describeSchema(flattened, flattened, describeProperty);
diff --git a/src/contracts/toolInputReferences.test.ts b/src/contracts/toolInputReferences.test.ts
index 4441ba9f7..c661fe040 100644
--- a/src/contracts/toolInputReferences.test.ts
+++ b/src/contracts/toolInputReferences.test.ts
@@ -97,9 +97,14 @@ describe("advertised input references", () => {
before: { description: "Exact byte range in the selected artifact." },
after: { description: "Exact byte range in the selected artifact." },
metadata: { default: literal, examples: [literal] },
- constructor: { description: "Value for constructor." },
},
});
+ // Inherited object keys never supply a description.
+ expect(inline).not.toHaveProperty([
+ "properties",
+ "constructor",
+ "description",
+ ]);
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false })
diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts
index 8f43f1708..e2a6e50af 100644
--- a/src/contracts/toolSchemaMetadata.ts
+++ b/src/contracts/toolSchemaMetadata.ts
@@ -164,7 +164,7 @@ const memoizeByTarget = (
};
};
-const fallbackPropertyDescription = (property: string): string => {
+const fallbackPropertyDescription = (property: string): string | undefined => {
const explicit = Object.hasOwn(PROPERTY_DESCRIPTIONS, property)
? PROPERTY_DESCRIPTIONS[property]
: undefined;
@@ -192,5 +192,6 @@ const fallbackPropertyDescription = (property: string): string => {
return `Local filesystem ${words} selected for this operation.`;
if (property.startsWith("is_") || property.startsWith("has_"))
return `Whether ${words}.`;
- return `Value for ${words}.`;
+ // A generic sentence would only restate the property name in every schema.
+ return undefined;
};
diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts
index 7c71a9b5d..b4fe4ad64 100644
--- a/src/domain/analysisErrorPresentation.ts
+++ b/src/domain/analysisErrorPresentation.ts
@@ -191,7 +191,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
const standardMessage = standardErrorMessage(error._tag);
if (standardMessage !== undefined) return standardMessage;
if (error instanceof ArtifactOperationError)
- return artifactMessage(error.operation, error.reason);
+ return artifactMessage(error.operation, error.reason, error.detail);
if (error instanceof EvidenceReferenceError)
return error.reason === "missing"
? `Evidence ${error.evidenceId} is not retained in this session. Supply complete inline Evidence, re-run its producer, or import its bundle before using this reference.`
@@ -329,13 +329,13 @@ const START_FAILURE_TAGS: ReadonlySet = new Set([
const artifactMessage = (
operation: ArtifactOperationError["operation"],
reason: ArtifactOperationError["reason"],
+ detail?: string,
): string => {
if (reason === "cancelled")
return "Artifact operation was cancelled. Start it again when ready.";
if (reason === "limit")
return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.";
- if (reason === "path")
- return "Artifact contains an unsafe or conflicting internal path. Inspect the reported path and correct the artifact before retrying.";
+ if (reason === "path") return artifactPathMessage(detail);
if (reason === "unavailable")
return "Artifact processing is unavailable for the current target or host. Check artifact support and required tools.";
if (reason === "integrity" && operation === "analyze_javascript_application")
@@ -345,6 +345,23 @@ const artifactMessage = (
return "Artifact could not be read or written. Check file access and try again.";
};
+const artifactPathMessage = (detail: string | undefined): string => {
+ // Kept in step with DESTINATION_CASE_COLLISION_PREFIX. Domain does not import
+ // the artifact adapter that produces the detail.
+ if (
+ detail !== undefined &&
+ detail.startsWith("Destination filesystem cannot store both ")
+ )
+ return detail;
+ if (
+ detail !== undefined &&
+ (detail.startsWith("Artifact path is absolute or unsafe:") ||
+ detail.startsWith("Artifact path is not normalized:"))
+ )
+ return "Artifact contains an unsafe internal path. Inspect the reported path and correct the artifact before retrying.";
+ return "Artifact contains a conflicting internal path. Inspect the reported path before retrying.";
+};
+
const evidenceFileMessage = ({
operation,
reason,
diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts
index 745b8e0c4..674425749 100644
--- a/src/domain/android/androidApplication.ts
+++ b/src/domain/android/androidApplication.ts
@@ -125,6 +125,8 @@ export const projectAndroidApplication = (
"Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.",
"Runtime families are inferred from inventory formats and paths; filename suffixes do not establish valid DEX or JVM class bytes.",
"Bridge candidates are path-based hypotheses, not decoded JNI declarations or observed runtime calls.",
+ "A bridge basis is inferred from the native path and is repeated for every managed component.",
+ "ZIP inventory cannot see the APK Signing Block, so an empty signing array does not mean the APK is unsigned.",
...(bridgeProjection.coverage.status === "partial"
? [
`Bridge candidate pairs exceeded the projection safety budget; ${bridgeProjection.coverage.omitted_candidates} hypotheses are omitted. Component arrays still include every component from the supplied inventory pages.`,
@@ -157,23 +159,12 @@ export const projectAndroidApplication = (
};
const classify = (all: readonly Component[]) => ({
- manifests: all.filter(
- ({ path, format }) =>
- format === "android-manifest" ||
- /(?:^|\/)AndroidManifest\.xml$/u.test(path),
- ),
- resources: all.filter(
- ({ path, format }) =>
- format === "android-resources" || /(?:^|\/)resources\.arsc$/u.test(path),
- ),
- dex: all.filter(
- ({ path, format }) =>
- format === "dex" || /(?:^|\/)[^/]+\.dex$/iu.test(path),
- ),
- jvm_classes: all.filter(
- ({ path, format }) =>
- format === "jvm-class" || /(?:^|\/)[^/]+\.class$/iu.test(path),
+ manifests: all.filter(({ path }) =>
+ /(?:^|\/)AndroidManifest\.xml$/u.test(path),
),
+ resources: all.filter(({ path }) => /(?:^|\/)resources\.arsc$/u.test(path)),
+ dex: all.filter(({ path }) => /(?:^|\/)[^/]+\.dex$/iu.test(path)),
+ jvm_classes: all.filter(({ path }) => /(?:^|\/)[^/]+\.class$/iu.test(path)),
native_libraries: all.filter(
({ path, format }) =>
format === "elf" || /(?:^|\/)lib\/[^/]+\/[^/]+\.so$/iu.test(path),
@@ -189,19 +180,9 @@ const runtimeFamilies = (all: readonly Component[]) => {
const families = new Set<
AndroidApplicationProjectionResult["runtime_families"][number]
>();
- if (
- all.some(
- ({ path, format }) =>
- format === "dex" || path.toLowerCase().endsWith(".dex"),
- )
- )
+ if (all.some(({ path }) => path.toLowerCase().endsWith(".dex")))
families.add("dalvik-art");
- if (
- all.some(
- ({ path, format }) =>
- format === "jvm-class" || path.toLowerCase().endsWith(".class"),
- )
- )
+ if (all.some(({ path }) => path.toLowerCase().endsWith(".class")))
families.add("java-kotlin");
if (all.some(({ format }) => format === "elf")) families.add("native");
if (all.some(({ format }) => format === "javascript-bundle"))
diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts
index fb1c0b9ad..bfa2c71de 100644
--- a/src/domain/apple/interfaceBuilderGraph.test.ts
+++ b/src/domain/apple/interfaceBuilderGraph.test.ts
@@ -552,3 +552,32 @@ it("names the archive once per evidence record", () => {
evidence.filter(({ description }) => description.includes(relativePath)),
).toEqual([]);
});
+
+it("states each object's class, label, and ID once", () => {
+ const { objects } = parseInterfaceBuilderRecords({
+ "com.apple.ibtool.document.objects": {
+ "7": {
+ customClass: "NSButton",
+ label: "Start",
+ objectID: "7",
+ nibValues: { NSContents: "Start" },
+ },
+ button: { class: "UIButton", title: "Build", objectID: "authored" },
+ },
+ });
+ expect(objects).toEqual([
+ expect.objectContaining({
+ id: "7",
+ class_name: "NSButton",
+ name: "Start",
+ attributes: { nibValues: { NSContents: "Start" } },
+ }),
+ expect.objectContaining({
+ id: "button",
+ class_name: "UIButton",
+ name: "Build",
+ // Values that differ from the canonical fields stay as authored.
+ attributes: { class: "UIButton", title: "Build", objectID: "authored" },
+ }),
+ ]);
+});
diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts
index 51b446b52..c0b7f3507 100644
--- a/src/domain/apple/interfaceBuilderKeyedArchive.ts
+++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts
@@ -71,13 +71,23 @@ export const parseInterfaceBuilderRecords = (
attributes["name"],
className,
);
+ // class_name, name, and id already carry these values; repeating them in
+ // attributes doubled them on every graph node.
+ const carried = new Set();
+ if (attributes["customClass"] === className) carried.add("customClass");
+ if (attributes["label"] === label) carried.add("label");
+ if (attributes["objectID"] === id) carried.add("objectID");
objects.push(
objectNode.parse({
id,
kind: classifyObject(className),
class_name: className,
name: label ?? id,
- attributes: jsonSafeRecord(attributes),
+ attributes: jsonSafeRecord(
+ Object.fromEntries(
+ Object.entries(attributes).filter(([key]) => !carried.has(key)),
+ ),
+ ),
}),
);
}
diff --git a/src/domain/artifactInspection.ts b/src/domain/artifactInspection.ts
index f4c197b9c..19ccb9b8f 100644
--- a/src/domain/artifactInspection.ts
+++ b/src/domain/artifactInspection.ts
@@ -16,30 +16,12 @@ const artifactIdSchema = prefixedDigestSchema("art");
const inspectionObservationSchema = z.strictObject({
observation_id: prefixedDigestSchema("aio"),
- kind: z.enum(["root-manifest", "artifact", "occurrence", "integrity"]),
+ kind: z.enum(["root-manifest", "integrity"]),
subject: textSchema,
value: jsonValueSchema,
evidence_id: evidenceIdSchema,
});
-const inspectionRelationshipSchema = z.strictObject({
- relationship_id: prefixedDigestSchema("air"),
- relation: z.enum([
- "contains",
- "extracts",
- "slice-of",
- "embeds",
- "loads",
- "maps-source",
- "derived-from",
- ]),
- source_artifact_id: artifactIdSchema,
- target_artifact_id: artifactIdSchema,
- occurrence_id: prefixedDigestSchema("occ"),
- logical_path: textSchema.nullable(),
- evidence_id: evidenceIdSchema,
-});
-
const inspectionHypothesisSchema = z.strictObject({
hypothesis_id: prefixedDigestSchema("aih"),
statement: textSchema,
@@ -97,7 +79,6 @@ export const artifactInspectionResultSchema = z.strictObject({
)
.min(1),
observations: z.array(inspectionObservationSchema),
- derived_relationships: z.array(inspectionRelationshipSchema),
hypotheses: z.array(inspectionHypothesisSchema),
contradictions: z.array(inspectionContradictionSchema),
unexplored_branches: z.array(unexploredBranchSchema),
@@ -107,8 +88,6 @@ export const artifactInspectionResultSchema = z.strictObject({
substeps_completed: z.literal(1),
observations_retained: z.number().int().min(0),
observations_omitted: z.number().int().min(0),
- relationships_retained: z.number().int().min(0),
- relationships_omitted: z.number().int().min(0),
hypotheses_retained: z.number().int().min(0),
hypotheses_omitted: z.number().int().min(0),
unexplored_branches_retained: z.number().int().min(0),
@@ -136,10 +115,6 @@ export const createArtifactInspection = (
inventory,
inventoryEvidence.evidence_id,
);
- const relationships = allRelationships(
- inventory,
- inventoryEvidence.evidence_id,
- );
const hypotheses = allHypotheses(inventory, inventoryEvidence.evidence_id);
const nextProbes = probesFor(inventory);
const branches = allUnexploredBranches(
@@ -149,14 +124,12 @@ export const createArtifactInspection = (
);
const retained = {
observations,
- relationships,
hypotheses,
branches,
probes: nextProbes,
};
const omissions = {
observations: 0,
- relationships: 0,
hypotheses: 0,
branches: 0,
probes: 0,
@@ -184,7 +157,6 @@ export const createArtifactInspection = (
},
],
observations: retained.observations,
- derived_relationships: retained.relationships,
hypotheses: retained.hypotheses,
contradictions: contradictions(inventory, inventoryEvidence.evidence_id),
unexplored_branches: retained.branches,
@@ -194,8 +166,6 @@ export const createArtifactInspection = (
substeps_completed: 1 as const,
observations_retained: retained.observations.length,
observations_omitted: omissions.observations,
- relationships_retained: retained.relationships.length,
- relationships_omitted: omissions.relationships,
hypotheses_retained: retained.hypotheses.length,
hypotheses_omitted: omissions.hypotheses,
unexplored_branches_retained: retained.branches.length,
@@ -224,6 +194,8 @@ const parseInventoryEvidence = (input: unknown): Evidence => {
return evidence;
};
+// Artifact, occurrence, and edge records stay in the nested inventory Evidence;
+// restating each one here would repeat the complete graph in every result.
const allObservations = (
inventory: ArtifactInventoryResult,
evidenceId: string,
@@ -234,12 +206,6 @@ const allObservations = (
inventory.manifest,
evidenceId,
),
- ...inventory.nodes.map((node) =>
- observation("artifact", node.artifact_id, node, evidenceId),
- ),
- ...inventory.occurrences.map((occurrence) =>
- observation("occurrence", occurrence.occurrence_id, occurrence, evidenceId),
- ),
...inventory.integrity_contradictions.map((value) =>
observation("integrity", value.contradiction_id, value, evidenceId),
),
@@ -263,25 +229,6 @@ const observation = (
};
};
-const allRelationships = (
- inventory: ArtifactInventoryResult,
- evidenceId: string,
-): ArtifactInspectionResult["derived_relationships"] =>
- inventory.edges.map((edge) => {
- const semantic = {
- relation: edge.relation,
- source_artifact_id: edge.parent_artifact_id,
- target_artifact_id: edge.child_artifact_id,
- occurrence_id: edge.occurrence_id,
- logical_path: edge.logical_path,
- evidence_id: evidenceId,
- };
- return {
- relationship_id: `air_${digestCanonicalValue(semantic, "Artifact inspection")}`,
- ...semantic,
- };
- });
-
const allHypotheses = (
inventory: ArtifactInventoryResult,
evidenceId: string,
diff --git a/src/domain/javascript/javascriptApplicationGraph.test.ts b/src/domain/javascript/javascriptApplicationGraph.test.ts
index efedf78c0..58f127928 100644
--- a/src/domain/javascript/javascriptApplicationGraph.test.ts
+++ b/src/domain/javascript/javascriptApplicationGraph.test.ts
@@ -547,6 +547,21 @@ describe("JavaScript Application Graph: source maps and observations", () => {
/Edge identifier/u,
);
});
+
+ it("creates exactly the graph the public schema accepts", () => {
+ const graph = buildSyntheticJavaScriptApplicationGraph();
+ expect(JSON.stringify(graph)).toBe(
+ JSON.stringify(
+ javascriptApplicationGraphSchema.parse(structuredClone(graph)),
+ ),
+ );
+
+ const { graph_id: _graphId, ...semantic } = structuredClone(graph);
+ firstOf(semantic.edges, "edge").edge_id = `jag_edge_${"f".repeat(64)}`;
+ expect(() => createJavaScriptApplicationGraph(semantic)).toThrow(
+ /Edge identifier/u,
+ );
+ });
});
describe("JavaScript Application Graph: duplicate and orphan rejection", () => {
diff --git a/src/domain/javascript/javascriptApplicationGraph.ts b/src/domain/javascript/javascriptApplicationGraph.ts
index ba122e1f0..e16aded75 100644
--- a/src/domain/javascript/javascriptApplicationGraph.ts
+++ b/src/domain/javascript/javascriptApplicationGraph.ts
@@ -304,8 +304,8 @@ const checkNode = (
type GraphRecord = z.infer;
-const checkGraphInvariants = (
- graph: GraphRecord,
+const checkGraphContent = (
+ graph: JavaScriptApplicationGraphInput,
context: z.RefinementCtx,
): void => {
sortedUniqueIssue(
@@ -394,7 +394,13 @@ const checkGraphInvariants = (
context,
);
}
+};
+const checkGraphInvariants = (
+ graph: GraphRecord,
+ context: z.RefinementCtx,
+): void => {
+ checkGraphContent(graph, context);
const { graph_id: identifier, ...semantic } = graph;
if (
identifier !==
@@ -411,6 +417,9 @@ const checkGraphInvariants = (
export const javascriptApplicationGraphSchema =
javascriptApplicationGraphRecordSchema.superRefine(checkGraphInvariants);
+const javascriptApplicationGraphContentSchema =
+ javascriptApplicationGraphInputSchema.superRefine(checkGraphContent);
+
/** Fully validated JavaScript Application Graph. */
export type JavaScriptApplicationGraph = z.infer<
typeof javascriptApplicationGraphSchema
@@ -482,10 +491,11 @@ export const createJavaScriptApplicationGraph = (
},
limitations: uniqueSorted(parsed.limitations),
};
- return javascriptApplicationGraphSchema.parse({
- ...semantic,
- graph_id: `jag_${digestCanonicalValue(semantic, "JavaScript Application Graph")}`,
- });
+ const content = javascriptApplicationGraphContentSchema.parse(semantic);
+ return {
+ ...content,
+ graph_id: `jag_${digestCanonicalValue(content, "JavaScript Application Graph")}`,
+ };
};
const validatedImmutableApplicationGraphs = new WeakSet
+
+ Want to go deeper?
+ Read the Blog →
+
Set up REA
@@ -1052,6 +1077,23 @@ SPEED: 6
+
diff --git a/website/public/showcase/aegis/index.html b/website/public/showcase/aegis/index.html
index 05a230501..de2227842 100644
--- a/website/public/showcase/aegis/index.html
+++ b/website/public/showcase/aegis/index.html
@@ -64,6 +64,7 @@
>