mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 01:09:38 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f9874cfe | ||
|
|
5f588485fe | ||
|
|
768a4c169d | ||
|
|
b55b0c317b | ||
|
|
7c1bd003ec | ||
|
|
22b5914c3d |
No files matched your search
@@ -60,7 +60,7 @@ pin inspection sources; they are not included in the npm package.
|
||||
| ghidra-nativeaot | [MIT](https://github.com/Washi1337/ghidra-nativeaot/blob/effeb734fc570c32650f88b159608979dc7b423e/LICENSE.md) | Pinned inspection source; derived parser attribution above. |
|
||||
| JADX | [Apache-2.0](https://github.com/skylot/jadx/blob/v1.5.6/LICENSE) | The audited jadx-headless-mcp source uses JADX 1.5.6 for Android decompilation; the engine JAR is separately supplied. |
|
||||
| jadx-headless-mcp | [Apache-2.0](https://github.com/1013503897/jadx-headless-mcp/blob/5844800a486d2248fa949b74c9896285f5b54de2/LICENSE) | v0.7.1; pinned submodule and user-supplied JAR. |
|
||||
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.990/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.990/NOTICE) | Bring your own `kuna`; REA verified 1.990 and never downloads, installs or bundles it. REA invokes it as a separate process. |
|
||||
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.989/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.989/NOTICE) | Bring your own `kuna`; REA verified 1.989 and never downloads, installs or bundles it. REA invokes it as a separate process. |
|
||||
| Apktool | [Apache-2.0](https://github.com/iBotPeaches/Apktool/blob/master/LICENSE.md) | User-supplied Android resource decoder. |
|
||||
| Binwalk | [MIT](https://github.com/ReFirmLabs/binwalk/blob/4fdab3d464d97b68e0af9088df3f9e2e1545b21c/LICENSE) | v3.1.0; pinned submodule and user-supplied firmware engine. |
|
||||
| Unblob | [MIT](https://github.com/onekey-sec/unblob/blob/1fcc7a0a584a70a96c31f5a276c20944d199a089/LICENSE) | 26.6.4; pinned submodule and user-supplied firmware engine. |
|
||||
|
||||
@@ -228,22 +228,6 @@ func processRunTokenObservation(
|
||||
parse: Result<ParsedRunToken, RunTokenReadError>,
|
||||
codeSigningFlags: () -> UInt32?
|
||||
) -> RunTokenObservation {
|
||||
// Native inspection restrictions apply even when argv is incomplete or
|
||||
// overwritten, before parsing reaches the environment. Keep this fact at
|
||||
// the native boundary; cleanup still decides whether the process is related.
|
||||
func withheldEnvironment() -> RunTokenObservation? {
|
||||
guard let flags = codeSigningFlags() else { return nil }
|
||||
if (flags & csPlatformBinary) != 0 {
|
||||
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
|
||||
}
|
||||
if (flags & csRestrict) != 0 {
|
||||
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func unavailableEnvironment(_ reason: String) -> RunTokenObservation {
|
||||
withheldEnvironment() ?? unavailable(pid: pid, reason: reason)
|
||||
}
|
||||
switch parse {
|
||||
case .success(let parsed):
|
||||
if let runId = parsed.runId {
|
||||
@@ -254,25 +238,40 @@ func processRunTokenObservation(
|
||||
if parsed.environmentEntryCount > 0 {
|
||||
return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil)
|
||||
}
|
||||
if let withheld = withheldEnvironment() { return withheld }
|
||||
let flags = codeSigningFlags()
|
||||
if let flags, (flags & csPlatformBinary) != 0 {
|
||||
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
|
||||
}
|
||||
if let flags, (flags & csRestrict) != 0 {
|
||||
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
|
||||
}
|
||||
return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil)
|
||||
case .failure(let failure):
|
||||
switch failure {
|
||||
case .environmentUnavailable:
|
||||
return unavailableEnvironment("environment_unavailable")
|
||||
let flags = codeSigningFlags()
|
||||
if let flags, (flags & csPlatformBinary) != 0 {
|
||||
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
|
||||
}
|
||||
if let flags, (flags & csRestrict) != 0 {
|
||||
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
|
||||
}
|
||||
return unavailable(pid: pid, reason: "environment_unavailable")
|
||||
case .ambiguousEnvironmentBoundary:
|
||||
return unavailableEnvironment("ambiguous_environment_boundary")
|
||||
// These failures already establish that token bytes were available.
|
||||
if let flags = codeSigningFlags(), (flags & csRestrict) != 0 {
|
||||
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
|
||||
}
|
||||
return unavailable(pid: pid, reason: "ambiguous_environment_boundary")
|
||||
case .duplicateToken:
|
||||
return unavailable(pid: pid, reason: "duplicate_run_token")
|
||||
case .invalidTokenEncoding:
|
||||
return unavailable(pid: pid, reason: "invalid_run_token_encoding")
|
||||
case .appleVectorUnavailable:
|
||||
return unavailableEnvironment("apple_vector_unavailable")
|
||||
return unavailable(pid: pid, reason: "apple_vector_unavailable")
|
||||
case .invalidBuffer:
|
||||
return unavailableEnvironment("malformed_procargs")
|
||||
return unavailable(pid: pid, reason: "malformed_procargs")
|
||||
case .invalidBufferAt(let stage):
|
||||
return unavailableEnvironment("malformed_procargs_\(stage.rawValue)")
|
||||
return unavailable(pid: pid, reason: "malformed_procargs_\(stage.rawValue)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,9 +612,6 @@ Saved snapshots include only cache entries whose subject identifies the selected
|
||||
target. Unbound observations and observations of child artifacts remain available
|
||||
in the open session and its Evidence bundle; they are excluded from the target's
|
||||
saved replay entries without changing their subjects.
|
||||
The snapshot also retains available Evidence linked by selected records, including
|
||||
transitive sources with an unbound or different subject. Those sources are not
|
||||
replay entries for the selected target. Unrelated records remain excluded.
|
||||
|
||||
Snapshots retain eligible target-scoped question histories in full, including
|
||||
mutation Evidence recorded while another target was active. Observations and
|
||||
|
||||
+1
-7
@@ -38,12 +38,6 @@ were examined; an unavailable section name does not erase those note facts.
|
||||
For GNU notes, `gnu.build_id_hex`, `gnu.abi_tag`, or `gnu.gold_version` are
|
||||
filled when the descriptor matches that type. Other owners keep `gnu` null.
|
||||
|
||||
For Linux `CORE` notes, `core.prpsinfo`, `core.file`, `core.siginfo`, or
|
||||
`core.auxv` are decoded for process arguments and status (`NT_PRPSINFO`), mapped
|
||||
file memory regions and paths (`NT_FILE`), fault signal and code (`NT_SIGINFO`), and
|
||||
auxiliary vectors (`NT_AUXV`) across 32-bit and 64-bit images. Other owners or unparsed
|
||||
notes keep `core` null.
|
||||
|
||||
`PT_NOTE` and `SHT_NOTE` are examined independently. The same file range may
|
||||
appear twice when both tables declare it.
|
||||
|
||||
@@ -54,7 +48,7 @@ the image never carried notes in another container or overlay.
|
||||
|
||||
The selected file is limited to 64 MiB by default (512 MiB maximum). At most
|
||||
32 note entries are examined by default across every table. Retained note
|
||||
names, section names, GNU descriptor text, and core dump string representations share a scan budget based on
|
||||
names, section names, and GNU descriptor text share a scan budget based on
|
||||
the source bytes plus any decoded section-name table, capped at
|
||||
`max_file_bytes`. The raw source and decoded table must also fit together
|
||||
within that ceiling. If admission fails, the error reports the required
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kuna provider
|
||||
|
||||
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.990, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases; `rea doctor` and analysis Evidence report them as unverified. Verification compares the reported version; it does not authenticate the executable. Analysis profiles retain the observed version and executable digest, independently of REA's current verified-release policy. The 1.990 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release.
|
||||
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.989, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases; `rea doctor` and analysis Evidence report them as unverified. Verification compares the reported version; it does not authenticate the executable. Analysis profiles retain the observed version and executable digest, independently of REA's current verified-release policy. The 1.989 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release.
|
||||
|
||||
Kuna is static: it reads the selected image and never executes it.
|
||||
|
||||
@@ -147,6 +147,7 @@ that rollback is resolved; a clean rollback permits another setup attempt.
|
||||
|
||||
## Provenance and limitations
|
||||
|
||||
- If a completed project export reports a failed function, `procedure_pseudo_code` with `analysis: "independent_fresh"` or `rea decompile --analysis independent_fresh` requests a new single-function attempt. It bypasses the project result and prior single-function and snapshot results; it does not replace the original observation. On a cold session it obtains an inventory without starting a project export. The independent attempt can use different analysis context and may also fail. Identical results can share an Evidence ID because Evidence is content-addressed.
|
||||
- Addresses and bytes are Kuna's observations of the image. Procedure entries and extents are its derivations: extents are upper bounds clipped at the next entry, and Kuna can split one function at an entry its own analysis infers. Pseudocode is its reconstruction, never original source.
|
||||
- Pseudocode and assembly are exact Kuna text; text from Kuna and from another provider is not comparable as equal or different.
|
||||
- Results are re-read rather than replayed from REA's analysis snapshots, because a cold answer can differ from the export's.
|
||||
|
||||
@@ -218,12 +218,6 @@ Malformed provider replies return `unreadable_output` for
|
||||
`resolve_containing_procedure` before successful-result projection;
|
||||
cancellation and incomplete cleanup remain errors.
|
||||
|
||||
For a procedure match, `procedure_references` with `direction: "incoming"` can
|
||||
provide the selected provider's reference kinds when supported. Its scope covers
|
||||
the procedure body, so compare target addresses when selecting references to the
|
||||
exact entry. It is a separate observation and does not establish runtime use or
|
||||
complete reference discovery.
|
||||
|
||||
REA prepares complete MCP results within the pinned stdio client's 10 MiB
|
||||
receive-buffer budget, including both text and structured representations and
|
||||
room for the JSON-RPC envelope. Tool errors omit `structuredContent` from the
|
||||
@@ -383,12 +377,6 @@ is null. `total` counts all matching heads; each page preserves complete records
|
||||
in stable unknown ID order. Filters apply before paging. Pages reflect current
|
||||
session state, so keep the unknown ledger unchanged while traversing it.
|
||||
|
||||
`list_unknowns` only lists registered heads. Import restores the Unknown records
|
||||
present in a bundle; it does not turn residual question text embedded in Evidence
|
||||
into registry entries. An empty registry therefore does not establish that
|
||||
retained Evidence has no open questions. Inspect the relevant Evidence or its
|
||||
analysis view for its residual questions.
|
||||
|
||||
Automatically retained workflow questions include their immutable result
|
||||
Evidence ID. Distinct results retain independent questions and source links;
|
||||
repeating the same result does not create another unknown. Workflow Evidence
|
||||
|
||||
@@ -227,7 +227,17 @@ caveats. Failure details retain at most 32 rows within 2,048 JSON-encoded
|
||||
UTF-8 bytes; names may be omitted to keep complete addresses and reasons. The
|
||||
index retains every failure and its complete observed name.
|
||||
Each function's `decompile-index.json` record, whatever its status, keeps the
|
||||
complete limitations of the observations it used. The returned Evidence keeps
|
||||
complete limitations of the observations it used.
|
||||
When available, `provider_source` records the source of that attempt's
|
||||
pseudocode observation: a file path, SHA-256 and byte range, or a
|
||||
single-function decompile. The latter describes the method, not a unique run. A file
|
||||
descriptor records the source as observed; the file may since have changed or
|
||||
been removed, so compare its current digest before using it. The descriptor
|
||||
does not authenticate the journal. `status: "unknown"` distinguishes
|
||||
an unavailable source from an existing matching output whose original producer
|
||||
is unknown. Older journal records can omit this field; omission is unknown.
|
||||
Failed attempts retain their source in the append-only journal when the
|
||||
provider supplied one, even if a later attempt succeeds. The returned Evidence keeps
|
||||
at most eight first-observed distinct per-function provider limitations within an 8,000-byte JSON-encoded UTF-8 text budget and counts omitted observations. Repeated omitted text is counted per observation, not as a distinct caveat. This summary does not retain every omitted string in memory. A fresh output directory must not already exist. `--resume`
|
||||
continues an export for the same target SHA-256, provider, analysis profile,
|
||||
and layout; it requires the directory to exist, and an export without a known
|
||||
@@ -300,20 +310,6 @@ relationships but reports their completeness as unknown. In particular, a resolv
|
||||
tail call observed in one direction does not prove that the reverse inventory is
|
||||
complete. An empty Hopper inventory cannot establish that no such call exists.
|
||||
No whole-binary reverse scan is performed.
|
||||
|
||||
For large native traces, `trace_feature` accepts `detail: "summary"`. It retains
|
||||
the complete trace and returns counts and its parent Evidence ID. Use
|
||||
`inspect_analysis_view` with that ID and `view: {kind: "native-feature-trace",
|
||||
collection: "references", offset: 0, limit: 64}` to page the original rows;
|
||||
`matches` and `unknowns` are separate collections. Matches accept an exact
|
||||
`address`; references accept exact `source_address` and `target_address`
|
||||
filters. Row indices refer to the retained collection, and page coverage does not establish provider discovery
|
||||
completeness. `list_unknowns` pages the independently managed question heads.
|
||||
In the one-shot CLI, `rea trace ... --detail summary --evidence_output PATH`
|
||||
stores the trace and its linked source Evidence for later
|
||||
`inspect-analysis-view` calls. This bundle does not include the CLI session's
|
||||
Unknown registry; its original question texts remain available in the trace's
|
||||
`unknowns` pages.
|
||||
Regex searches use ECMAScript Unicode syntax in a cancellable worker with a
|
||||
matching deadline and a worker old-generation heap. Deadline or cancellation
|
||||
stops matching while leaving the Hopper API available. The deadline is
|
||||
|
||||
@@ -14,13 +14,7 @@ can run.
|
||||
|
||||
## GNU objdump
|
||||
|
||||
REA sends GNU-style arguments to the selected command but does not verify its
|
||||
implementation or version. Evidence identifies the command profile as
|
||||
`objdump.command` and retains the selected executable; it does not claim that
|
||||
the executable is GNU objdump. Other implementations may accept some commands
|
||||
while differing in supported options and output.
|
||||
|
||||
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless Evidence containing the selected command’s output, not a guarantee that any target is supported. GNU objdump uses this option to list its compiled-in BFD targets.
|
||||
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless provider-capability Evidence describing BFD targets compiled into the installed binary, not a guarantee that a target is supported.
|
||||
|
||||
Failure to launch the selected executable reports provider unavailability, the
|
||||
configured command and the host error under `boundary: "provider-launch"`.
|
||||
@@ -31,7 +25,7 @@ their rejected fields in input issues before artifact reads or provider launch.
|
||||
|
||||
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
|
||||
|
||||
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the selected executable's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
|
||||
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
|
||||
|
||||
```sh
|
||||
rea inspect-with-objdump ./program disassemble
|
||||
|
||||
@@ -459,11 +459,6 @@ symbol-bearing and stripped Mach-O fixtures, including cold and warm reads,
|
||||
references, and cleanup. Linux, other hosts, and the larger 1.833 target matrix
|
||||
were not verified with 1.989.
|
||||
|
||||
Kuna 1.990 passed the real-provider lane on macOS arm64 with the generated
|
||||
symbol-bearing and stripped Mach-O fixtures, including cold and warm reads,
|
||||
references, timeout recovery, and cleanup. Linux, other hosts, and the larger
|
||||
1.833 target matrix were not verified with 1.990.
|
||||
|
||||
## DOS Ghidra analysis
|
||||
|
||||
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
|
||||
|
||||
@@ -114,46 +114,6 @@ export const verifyKunaReferences = async ({
|
||||
);
|
||||
}
|
||||
assert.equal(trace.value.truncated, false);
|
||||
const summary = await call("trace_feature", {
|
||||
query: name,
|
||||
detail: "summary",
|
||||
});
|
||||
assert.equal(
|
||||
summary.value.parent_evidence_id,
|
||||
trace.structured.evidence_id,
|
||||
);
|
||||
assert.equal(
|
||||
summary.value.summary.references,
|
||||
trace.value.references.length,
|
||||
);
|
||||
assert.equal(
|
||||
summary.value.summary.unknowns,
|
||||
trace.value.residual_unknowns.length,
|
||||
);
|
||||
for (const [collection, values] of [
|
||||
["matches", trace.value.matches],
|
||||
["references", trace.value.references],
|
||||
["unknowns", trace.value.residual_unknowns],
|
||||
]) {
|
||||
const view = await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: trace.structured.evidence_id,
|
||||
},
|
||||
view: {
|
||||
kind: "native-feature-trace",
|
||||
collection,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
assert.equal(view.value.coverage.total, values.length);
|
||||
assert.deepEqual(
|
||||
view.value.items.rows.map(({ value }) => value),
|
||||
values.slice(0, 1),
|
||||
);
|
||||
assert.equal(view.value.items.collection, collection);
|
||||
}
|
||||
}
|
||||
composed = {
|
||||
graph_ms: graph.ms,
|
||||
|
||||
@@ -18,6 +18,10 @@ import type {
|
||||
EvidenceSubjectTarget,
|
||||
} from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
analysisSourceDescriptorSchema,
|
||||
type AnalysisSourceDescriptor,
|
||||
} from "../domain/analysisSource.js";
|
||||
import type { ProgressReporter } from "./ProgressReporter.js";
|
||||
import type { ProviderRejectionCode } from "../domain/providerSelection.js";
|
||||
import type { ProcessLineageObservation } from "../process/ProcessOwnership.js";
|
||||
@@ -56,6 +60,12 @@ export type AnalysisOperation =
|
||||
/** Atomic successful provider observation with exact producing provenance. */
|
||||
export interface AnalysisExecution extends AnalysisObservation {
|
||||
readonly provider: ProviderIdentity;
|
||||
/**
|
||||
* Compact provider-owned facts for this execution. This is not an Evidence
|
||||
* record and is not restored by snapshot replay; consumers must treat its
|
||||
* absence as unknown.
|
||||
*/
|
||||
readonly sourceDescriptor?: AnalysisSourceDescriptor;
|
||||
/** Session-issued identity of the committed record consumed by this execution. */
|
||||
readonly evidenceId?: string;
|
||||
/** Result-specific veto of snapshot replay; Evidence remains retainable. */
|
||||
@@ -71,6 +81,7 @@ export interface AnalysisExecution extends AnalysisObservation {
|
||||
|
||||
interface CreateAnalysisExecutionOptions {
|
||||
readonly rawResult?: unknown;
|
||||
readonly sourceDescriptor?: AnalysisSourceDescriptor;
|
||||
readonly analysisProfile?: AnalysisProfileCommitment;
|
||||
readonly cachePolicy?: "live";
|
||||
readonly limitations?: readonly string[];
|
||||
@@ -110,6 +121,13 @@ export function createAnalysisExecution(
|
||||
? null
|
||||
: jsonValueSchema.parse(options.rawResult),
|
||||
provider,
|
||||
...(options.sourceDescriptor === undefined
|
||||
? {}
|
||||
: {
|
||||
sourceDescriptor: analysisSourceDescriptorSchema.parse(
|
||||
options.sourceDescriptor,
|
||||
),
|
||||
}),
|
||||
...(options.cachePolicy === undefined
|
||||
? {}
|
||||
: { cachePolicy: options.cachePolicy }),
|
||||
@@ -275,6 +293,7 @@ export type CapabilityDescriptor = CapabilityAvailability & {
|
||||
* exclusions apply to every policy.
|
||||
*/
|
||||
readonly cachePolicy?: "snapshot" | "live";
|
||||
readonly independentDecompile?: true;
|
||||
/** Exact source profile for an operation implemented by a versioned local reader. */
|
||||
readonly analysisProfile?: AnalysisProfileCommitment;
|
||||
readonly provider: ProviderIdentity;
|
||||
|
||||
@@ -18,14 +18,7 @@ import { silentLogger } from "../logger.js";
|
||||
import type { Logger } from "pino";
|
||||
import { withEvidenceCallerInputPath } from "../domain/evidence.js";
|
||||
import { createImmutableEvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
retainAnalysisResultSources,
|
||||
type AnalysisResultWithSources,
|
||||
} from "./investigation/recordEvidenceSources.js";
|
||||
import {
|
||||
createInvestigationEvidenceClosureChecker,
|
||||
resolveInvestigationEvidenceClosure,
|
||||
} from "./investigation/InvestigationEvidence.js";
|
||||
import { retainAnalysisResultSources } from "./investigation/recordEvidenceSources.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
|
||||
@@ -45,7 +38,10 @@ import {
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js";
|
||||
import {
|
||||
requestsFreshIndependentDecompile,
|
||||
requiresNativeSourceAdmissionBeforeReplay,
|
||||
} from "./binary/AnalysisSnapshotCache.js";
|
||||
import { classifyBinaryTargetFilesystemFailure } from "./BinaryTargetResolver.js";
|
||||
import {
|
||||
snapshotBindingMismatch,
|
||||
@@ -146,17 +142,13 @@ export const runDirectAnalysis = async (
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
readonly entryPoint?: string;
|
||||
readonly espFlash?: EspFlashSelection;
|
||||
readonly includeEvidenceSources?: boolean;
|
||||
} = {},
|
||||
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> =>
|
||||
): Promise<Result<Evidence, JsonValue>> =>
|
||||
withProcessCancellation(options.signal, (signal) =>
|
||||
runAnalysis(dependencies, path, tool, arguments_, {
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
signal,
|
||||
...(options.includeEvidenceSources
|
||||
? { includeEvidenceSources: true }
|
||||
: {}),
|
||||
...targetSelection(options),
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
@@ -376,14 +368,13 @@ const runAnalysis = async (
|
||||
readonly signal: AbortSignal;
|
||||
readonly evidenceParameters?: Readonly<Record<string, JsonValue>>;
|
||||
readonly createSession?: () => BinarySession | Promise<BinarySession>;
|
||||
readonly includeEvidenceSources?: boolean;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
readonly formatHint?: ExecutableFormatHint;
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
readonly entryPoint?: string;
|
||||
readonly espFlash?: EspFlashSelection;
|
||||
},
|
||||
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> => {
|
||||
): Promise<Result<Evidence, JsonValue>> => {
|
||||
const { logger, signal, snapshotPath } = options;
|
||||
const evidenceParameters = options.evidenceParameters ?? arguments_;
|
||||
let session: BinarySession;
|
||||
@@ -399,29 +390,6 @@ const runAnalysis = async (
|
||||
)(config.value, logger);
|
||||
}
|
||||
let publishedSnapshot: PublishedEvidenceFile | undefined;
|
||||
let sourceEvidence: readonly Evidence[] | undefined;
|
||||
const captureSources = (
|
||||
evidence: Evidence,
|
||||
supplied: readonly Evidence[] = [],
|
||||
): Result<null, AnalysisError> => {
|
||||
if (!options.includeEvidenceSources) return ok(null);
|
||||
const records = new Map(
|
||||
supplied.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
const captured = new Map<string, Evidence>();
|
||||
const closure = resolveInvestigationEvidenceClosure(
|
||||
{
|
||||
evidenceForAnalysis: (id) => {
|
||||
const record = records.get(id) ?? session.evidenceForAnalysis(id);
|
||||
if (record !== undefined) captured.set(id, record);
|
||||
return record;
|
||||
},
|
||||
},
|
||||
evidence.evidence_links,
|
||||
);
|
||||
sourceEvidence = [...captured.values()];
|
||||
return closure.ok ? ok(null) : closure;
|
||||
};
|
||||
const execute = async (): Promise<Result<Evidence, AnalysisError>> => {
|
||||
let isDirectory = false;
|
||||
if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) {
|
||||
@@ -481,6 +449,7 @@ const runAnalysis = async (
|
||||
) &&
|
||||
tool !== "trace_native_ui_action" &&
|
||||
tool !== "decompile_all" &&
|
||||
!requestsFreshIndependentDecompile(tool, arguments_) &&
|
||||
routeAllowsSnapshotReplay(route, tool)
|
||||
) {
|
||||
const cached = snapshotEvidenceForQuery(snapshot, {
|
||||
@@ -493,29 +462,8 @@ const runAnalysis = async (
|
||||
: routeProviderIdentity(route, tool),
|
||||
evidenceProfile,
|
||||
});
|
||||
if (cached !== undefined) {
|
||||
if (cached.evidence_links.length === 0)
|
||||
return ok(withEvidenceCallerInputPath(cached, path));
|
||||
const records = new Map(
|
||||
snapshot.evidence_bundle.records.map((record) => [
|
||||
record.evidence_id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const checkClosure = createInvestigationEvidenceClosureChecker({
|
||||
evidenceForAnalysis: (id) => records.get(id),
|
||||
});
|
||||
if (checkClosure(cached.evidence_links)) {
|
||||
const evidence = withEvidenceCallerInputPath(cached, path);
|
||||
const captured = captureSources(
|
||||
evidence,
|
||||
snapshot.evidence_bundle.records,
|
||||
);
|
||||
if (!captured.ok)
|
||||
return err(captured.error.retainPartialObservation(evidence));
|
||||
return ok(evidence);
|
||||
}
|
||||
}
|
||||
if (cached !== undefined)
|
||||
return ok(withEvidenceCallerInputPath(cached, path));
|
||||
}
|
||||
}
|
||||
const openOptions = {
|
||||
@@ -552,9 +500,6 @@ const runAnalysis = async (
|
||||
);
|
||||
if (!recorded.ok)
|
||||
return err(recorded.error.retainPartialObservation(evidence));
|
||||
const captured = captureSources(evidence);
|
||||
if (!captured.ok)
|
||||
return err(captured.error.retainPartialObservation(evidence));
|
||||
if (isWorkflowEvidenceTool(tool)) {
|
||||
const unknowns = recordWorkflowUnknowns({
|
||||
name: tool,
|
||||
@@ -634,15 +579,11 @@ const runAnalysis = async (
|
||||
outcome.cleanupError.retainDiagnostics({
|
||||
analysis_snapshot: { ...publishedSnapshot },
|
||||
});
|
||||
return {
|
||||
...err(directAnalysisCleanupFailure(result, outcome.cleanupError, path)),
|
||||
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
|
||||
};
|
||||
return err(
|
||||
directAnalysisCleanupFailure(result, outcome.cleanupError, path),
|
||||
);
|
||||
}
|
||||
return {
|
||||
...(result.ok ? ok(result.value) : err(cliError(result.error, path))),
|
||||
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
|
||||
};
|
||||
return result.ok ? ok(result.value) : err(cliError(result.error, path));
|
||||
};
|
||||
|
||||
const executeAnalysisTool = async (input: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { z } from "zod";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { LiteralFeatureReference } from "../domain/native/literalFeatureTrace.js";
|
||||
import type { TraceContainingProcedure } from "../domain/native/nativeObservation.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import {
|
||||
enhancedInputSchemas,
|
||||
@@ -27,7 +27,11 @@ export const failedWithPartialResult = (
|
||||
error: AnalysisError,
|
||||
partialResult: JsonValue,
|
||||
): PartialEnhancedFailure => ({ ok: false, error, partialResult });
|
||||
export type TraceReference = LiteralFeatureReference;
|
||||
export type TraceReference = {
|
||||
readonly target_address: string;
|
||||
readonly source_address: string;
|
||||
readonly containing_procedure: TraceContainingProcedure;
|
||||
};
|
||||
|
||||
/** One enhanced call whose input was parsed at its owning adapter boundary. */
|
||||
export type ValidatedEnhancedCall = {
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
} from "../../domain/analysisSnapshot.fixture.js";
|
||||
import { createEvidence, immutableEvidence } from "../../domain/evidence.js";
|
||||
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
evidenceBundleForTarget,
|
||||
@@ -1209,154 +1208,6 @@ it("declines source-bearing snapshot entries while replaying omitted and empty s
|
||||
expect(cache.entries()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("replays source-bearing entries only when their complete retained closure exists", () => {
|
||||
const target = ANALYSIS_SNAPSHOT_TARGET;
|
||||
const profile = ANALYSIS_SNAPSHOT_PROFILE;
|
||||
const leaf = immutableEvidence(
|
||||
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
|
||||
operation: "leaf",
|
||||
parameters: {},
|
||||
result: "leaf observation",
|
||||
}),
|
||||
);
|
||||
const source = immutableEvidence(
|
||||
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
|
||||
operation: "source",
|
||||
parameters: {},
|
||||
result: "source observation",
|
||||
evidenceLinks: [leaf.evidence_id],
|
||||
}),
|
||||
);
|
||||
const parameters = { address: "0x1000", document: "fixture" };
|
||||
const execution = createAnalysisExecution(
|
||||
"parent observation",
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{
|
||||
subject: target,
|
||||
analysisProfile: profile,
|
||||
sourceEvidence: [source],
|
||||
},
|
||||
);
|
||||
const parent = immutableEvidence(
|
||||
createAnalysisExecutionEvidence(
|
||||
"procedure_info",
|
||||
parameters,
|
||||
execution,
|
||||
"observed",
|
||||
),
|
||||
);
|
||||
const complete = new Map(
|
||||
[parent, source, leaf].map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
const input = {
|
||||
target,
|
||||
profile,
|
||||
operation: "procedure_info" as const,
|
||||
parameters,
|
||||
execution: { ...execution, evidenceId: parent.evidence_id },
|
||||
};
|
||||
const missingLeaf = new AnalysisSnapshotCache();
|
||||
missingLeaf.record(input, (id) =>
|
||||
id === leaf.evidence_id ? undefined : complete.get(id),
|
||||
);
|
||||
expect(
|
||||
missingLeaf.lookup(target, profile, "procedure_info", parameters),
|
||||
).toBeUndefined();
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
cache.record(input, (id) => complete.get(id));
|
||||
expect(
|
||||
cache
|
||||
.lookup(target, profile, "procedure_info", parameters)
|
||||
?.sourceEvidence?.map((record) => record.evidence_id),
|
||||
).toEqual([source.evidence_id]);
|
||||
if (source.subject === null) throw new Error("Expected source subject");
|
||||
const ledger = new EvidenceLedger();
|
||||
for (const record of [parent, source, leaf])
|
||||
expect(ledger.record(record).ok).toBe(true);
|
||||
const movedSource = {
|
||||
...source,
|
||||
subject: {
|
||||
...source.subject,
|
||||
local_path: "/relocated/source",
|
||||
name: "source",
|
||||
},
|
||||
};
|
||||
const replacement = ledger.import(createEvidenceBundle([movedSource]));
|
||||
if (!replacement.ok) throw replacement.error;
|
||||
cache.refreshEvidenceMetadata(replacement.value.replacements);
|
||||
expect(
|
||||
cache.lookup(target, profile, "procedure_info", parameters)
|
||||
?.sourceEvidence?.[0]?.subject?.local_path,
|
||||
).toBe("/relocated/source");
|
||||
const exported = cache.export(target, profile, ledger.export());
|
||||
if (!exported.ok) throw exported.error;
|
||||
const imported = new AnalysisSnapshotCache();
|
||||
imported.stage(exported.value);
|
||||
expect(
|
||||
imported
|
||||
.lookup(target, profile, "procedure_info", parameters)
|
||||
?.sourceEvidence?.map((record) => record.evidence_id),
|
||||
).toEqual([source.evidence_id]);
|
||||
expect(
|
||||
imported.lookup(target, profile, "procedure_info", parameters)
|
||||
?.sourceEvidence?.[0]?.subject?.local_path,
|
||||
).toBe("/relocated/source");
|
||||
const pruned = parseAnalysisSnapshot({
|
||||
...exported.value,
|
||||
evidence_bundle: createEvidenceBundle([parent, movedSource]),
|
||||
});
|
||||
const incomplete = new AnalysisSnapshotCache();
|
||||
incomplete.stage(pruned);
|
||||
const partial = new Map(
|
||||
[parent, movedSource].map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
expect(
|
||||
incomplete.lookup(target, profile, "procedure_info", parameters, (id) =>
|
||||
partial.get(id),
|
||||
),
|
||||
).toBeUndefined();
|
||||
partial.set(leaf.evidence_id, leaf);
|
||||
expect(
|
||||
incomplete
|
||||
.lookup(target, profile, "procedure_info", parameters, (id) =>
|
||||
partial.get(id),
|
||||
)
|
||||
?.sourceEvidence?.map((record) => record.evidence_id),
|
||||
).toEqual([source.evidence_id]);
|
||||
|
||||
const otherSource = immutableEvidence(
|
||||
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
|
||||
operation: "other_source",
|
||||
parameters: {},
|
||||
result: "different source",
|
||||
}),
|
||||
);
|
||||
const otherParent = immutableEvidence(
|
||||
createAnalysisExecutionEvidence(
|
||||
"procedure_info",
|
||||
parameters,
|
||||
{ ...execution, sourceEvidence: [otherSource] },
|
||||
"observed",
|
||||
),
|
||||
);
|
||||
const records = [parent, movedSource, leaf, otherParent, otherSource];
|
||||
const ambiguous = new AnalysisSnapshotCache();
|
||||
ambiguous.stage(
|
||||
parseAnalysisSnapshot({
|
||||
...exported.value,
|
||||
evidence_bundle: createEvidenceBundle(records),
|
||||
}),
|
||||
);
|
||||
const allRecords = new Map(
|
||||
records.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
expect(
|
||||
ambiguous.lookup(target, profile, "procedure_info", parameters, (id) =>
|
||||
allRecords.get(id),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("executions that report truncated references", () => {
|
||||
const record = (referencesTruncated: boolean | undefined) => {
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
snapshotReplayBinding,
|
||||
type SnapshotReplayBinding,
|
||||
} from "./AnalysisSnapshotReplayBinding.js";
|
||||
import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js";
|
||||
|
||||
const RELATIONSHIP_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"procedure_callers",
|
||||
@@ -126,12 +125,21 @@ export const isSourceEvidenceRetainable = (
|
||||
descriptor.effects.mayWriteFilesystem === false) &&
|
||||
descriptor.effects.changesPermissions === false;
|
||||
|
||||
/** Explicit independent decompiles must run even if a snapshot has a matching entry. */
|
||||
export const requestsFreshIndependentDecompile = (
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): boolean =>
|
||||
operation === "procedure_pseudo_code" &&
|
||||
parameters.analysis === "independent_fresh";
|
||||
|
||||
/** Whether an observation can additionally be replayed as a snapshot. */
|
||||
export const isSnapshotCacheable = (
|
||||
operation: AnalysisOperation,
|
||||
descriptor: CapabilityDescriptor | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): descriptor is CapabilityDescriptor =>
|
||||
!requestsFreshIndependentDecompile(operation, parameters) &&
|
||||
isSourceEvidenceRetainable(operation, descriptor, parameters) &&
|
||||
replayDocumentSelected(operation, parameters) &&
|
||||
descriptor.cachePolicy !== "live";
|
||||
@@ -148,12 +156,9 @@ export const SNAPSHOT_REPLAY_LIMITATION =
|
||||
/** Bounded in-memory cache for one immutable binary identity. */
|
||||
export class AnalysisSnapshotCache {
|
||||
readonly #entries = new Map<string, AnalysisSnapshotEntry>();
|
||||
// Null is ambiguous provenance; a pending ID is an exact parent whose source
|
||||
// closure was missing and can be checked again against the owning ledger.
|
||||
readonly #replayBindings = new Map<
|
||||
string,
|
||||
SnapshotReplayBinding | { readonly pendingEvidenceId: string } | null
|
||||
>();
|
||||
// Null means imported provenance cannot be faithfully replayed. It is distinct
|
||||
// from an entry recorded before an Evidence ID was assigned.
|
||||
readonly #replayBindings = new Map<string, SnapshotReplayBinding | null>();
|
||||
readonly #workflowEntries = new Map<string, AnalysisSnapshotWorkflowEntry>();
|
||||
#target: AnalysisSnapshotTarget | undefined;
|
||||
#binding: AnalysisSnapshotBinding | undefined;
|
||||
@@ -222,10 +227,6 @@ export class AnalysisSnapshotCache {
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const lookup = (id: string) => records.get(id);
|
||||
const checkClosure = createInvestigationEvidenceClosureChecker({
|
||||
evidenceForAnalysis: lookup,
|
||||
});
|
||||
const ambiguous = new Set<string>();
|
||||
for (const evidence of snapshot.evidence_bundle.records) {
|
||||
const queryId = analysisQueryId(
|
||||
@@ -250,15 +251,11 @@ export class AnalysisSnapshotCache {
|
||||
else evidenceByQuery.set(queryId, evidence);
|
||||
}
|
||||
for (const [queryId, evidence] of evidenceByQuery) {
|
||||
if (ambiguous.has(queryId)) {
|
||||
this.#replayBindings.set(queryId, null);
|
||||
continue;
|
||||
}
|
||||
this.#replayBindings.set(
|
||||
queryId,
|
||||
snapshotReplayBinding(evidence, lookup, checkClosure) ?? {
|
||||
pendingEvidenceId: evidence.evidence_id,
|
||||
},
|
||||
ambiguous.has(queryId)
|
||||
? null
|
||||
: snapshotReplayBinding(evidence, records),
|
||||
);
|
||||
}
|
||||
for (const entry of snapshot.workflow_entries) {
|
||||
@@ -284,12 +281,7 @@ export class AnalysisSnapshotCache {
|
||||
replacements.map(({ current }) => [current.evidence_id, current]),
|
||||
);
|
||||
for (const [queryId, binding] of this.#replayBindings) {
|
||||
if (
|
||||
binding === null ||
|
||||
"pendingEvidenceId" in binding ||
|
||||
binding.sourceEvidence === undefined
|
||||
)
|
||||
continue;
|
||||
if (binding === null || binding.sourceEvidence === undefined) continue;
|
||||
const sources = binding.sourceEvidence;
|
||||
if (!sources.some((source) => currentRecords.has(source.evidence_id)))
|
||||
continue;
|
||||
@@ -492,7 +484,6 @@ export class AnalysisSnapshotCache {
|
||||
profile: AnalysisProfileCommitment,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
lookup?: (id: string) => Evidence | undefined,
|
||||
): AnalysisExecution | undefined {
|
||||
const queryId = analysisQueryId(
|
||||
snapshotTarget(target),
|
||||
@@ -502,18 +493,10 @@ export class AnalysisSnapshotCache {
|
||||
);
|
||||
const cached = this.#entries.get(queryId);
|
||||
if (cached === undefined) return undefined;
|
||||
let binding = this.#replayBindings.get(queryId);
|
||||
const binding = this.#replayBindings.get(queryId);
|
||||
// Do not turn a refused provenance selection into a fresh, unlinked record.
|
||||
// The provider must produce a new observation before this query can replay.
|
||||
if (binding === null) return undefined;
|
||||
if (binding !== undefined && "pendingEvidenceId" in binding) {
|
||||
const parent = lookup?.(binding.pendingEvidenceId);
|
||||
if (parent === undefined || lookup === undefined) return undefined;
|
||||
const restored = snapshotReplayBinding(parent, lookup);
|
||||
if (restored === null) return undefined;
|
||||
this.#replayBindings.set(queryId, restored);
|
||||
binding = restored;
|
||||
}
|
||||
const cachedSubject = cached.execution.subject;
|
||||
const subject =
|
||||
cachedSubject == null
|
||||
@@ -561,30 +544,21 @@ export class AnalysisSnapshotCache {
|
||||
}
|
||||
|
||||
/** Record one successful immutable call unless the cache is full. */
|
||||
record(
|
||||
input: {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly execution: AnalysisExecution;
|
||||
},
|
||||
lookup?: (id: string) => Evidence | undefined,
|
||||
): void {
|
||||
record(input: {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly execution: AnalysisExecution;
|
||||
}): void {
|
||||
const { target, profile, operation, parameters, execution } = input;
|
||||
this.select(target, profile);
|
||||
// The snapshot format does not carry reference truncation; a replay would
|
||||
// present a capped read as complete, so it is not recorded.
|
||||
if (execution.referencesTruncated === true) return;
|
||||
let sourceBinding: SnapshotReplayBinding | undefined;
|
||||
if ((execution.sourceEvidence?.length ?? 0) > 0) {
|
||||
if (execution.evidenceId === undefined || lookup === undefined) return;
|
||||
const parent = lookup(execution.evidenceId);
|
||||
if (parent === undefined) return;
|
||||
const closed = snapshotReplayBinding(parent, lookup);
|
||||
if (closed === null) return;
|
||||
sourceBinding = closed;
|
||||
}
|
||||
// Provider snapshot entries do not carry execution source records. Replay
|
||||
// must not discard the source closure that supplies the parent's Evidence links.
|
||||
if ((execution.sourceEvidence?.length ?? 0) > 0) return;
|
||||
const queryId = analysisQueryId(
|
||||
snapshotTarget(target),
|
||||
snapshotBinding(profile),
|
||||
@@ -616,10 +590,9 @@ export class AnalysisSnapshotCache {
|
||||
if (execution.evidenceId === undefined)
|
||||
this.#replayBindings.delete(entry.query_id);
|
||||
else
|
||||
this.#replayBindings.set(
|
||||
entry.query_id,
|
||||
sourceBinding ?? { evidenceId: execution.evidenceId },
|
||||
);
|
||||
this.#replayBindings.set(entry.query_id, {
|
||||
evidenceId: execution.evidenceId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget all target-bound entries. */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js";
|
||||
|
||||
/** Authenticated observation identity and its immediate source records. */
|
||||
export interface SnapshotReplayBinding {
|
||||
@@ -10,17 +9,13 @@ export interface SnapshotReplayBinding {
|
||||
/** Preserve direct links using the snapshot's authenticated immutable records. */
|
||||
export const snapshotReplayBinding = (
|
||||
evidence: Evidence,
|
||||
lookup: (id: string) => Evidence | undefined,
|
||||
checkClosure = createInvestigationEvidenceClosureChecker({
|
||||
evidenceForAnalysis: lookup,
|
||||
}),
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
): SnapshotReplayBinding | null => {
|
||||
if (evidence.evidence_links.length === 0)
|
||||
return { evidenceId: evidence.evidence_id };
|
||||
if (!checkClosure(evidence.evidence_links)) return null;
|
||||
const sources: Evidence[] = [];
|
||||
for (const id of evidence.evidence_links) {
|
||||
const source = lookup(id);
|
||||
const source = records.get(id);
|
||||
// An external link without its source cannot be represented by sourceEvidence.
|
||||
if (source === undefined) return null;
|
||||
sources.push(source);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
isSnapshotCacheable,
|
||||
requestsFreshIndependentDecompile,
|
||||
requiresNativeSourceAdmissionBeforeReplay,
|
||||
} from "./AnalysisSnapshotCache.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
@@ -79,6 +80,17 @@ export const prepareSessionExecution = (
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
requestsFreshIndependentDecompile(operation, parameters) &&
|
||||
capability.independentDecompile !== true
|
||||
)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
capability.provider.id,
|
||||
operation,
|
||||
"This provider does not declare fresh independent decompilation.",
|
||||
),
|
||||
);
|
||||
const profile =
|
||||
active.profile !== null &&
|
||||
capability.provider.id === active.profile.provider.id
|
||||
|
||||
@@ -323,16 +323,14 @@ export abstract class BinarySessionRecords {
|
||||
>,
|
||||
): AnalysisExecution | undefined {
|
||||
if (this.#snapshotInvalidated) return undefined;
|
||||
return this.#snapshot.lookup(target, profile, operation, parameters, (id) =>
|
||||
this.evidenceForAnalysis(id),
|
||||
);
|
||||
return this.#snapshot.lookup(target, profile, operation, parameters);
|
||||
}
|
||||
|
||||
protected recordSnapshot(
|
||||
input: Parameters<AnalysisSnapshotCache["record"]>[0],
|
||||
): void {
|
||||
if (this.#snapshotInvalidated) return;
|
||||
this.#snapshot.record(input, (id) => this.evidenceForAnalysis(id));
|
||||
this.#snapshot.record(input);
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +414,7 @@ export const providerCapabilitySets = (
|
||||
const key = JSON.stringify([
|
||||
effects,
|
||||
limitations,
|
||||
descriptor.independentDecompile ?? null,
|
||||
status.available,
|
||||
status.reason,
|
||||
status.availability_code,
|
||||
@@ -425,6 +426,9 @@ export const providerCapabilitySets = (
|
||||
operations: [descriptor.operation],
|
||||
effects,
|
||||
limitations,
|
||||
...(descriptor.independentDecompile === undefined
|
||||
? {}
|
||||
: { independent_decompile: descriptor.independentDecompile }),
|
||||
...status,
|
||||
});
|
||||
else group.operations.push(descriptor.operation);
|
||||
|
||||
@@ -535,6 +535,12 @@ const publishOne = async (input: {
|
||||
const caveats = [
|
||||
...new Set([...infoCaveats, ...decompiled.value.limitations]),
|
||||
];
|
||||
const observationSource: NonNullable<
|
||||
DecompileDirectoryFunctionRecord["provider_source"]
|
||||
> =
|
||||
decompiled.value.sourceDescriptor === undefined
|
||||
? { status: "unknown", reason: "source_not_available" }
|
||||
: { status: "reported", source: decompiled.value.sourceDescriptor };
|
||||
const pseudocode = decompiled.value.result;
|
||||
if (typeof pseudocode !== "string" || pseudocode.length === 0) {
|
||||
const reason = pseudocodeFailure(pseudocode);
|
||||
@@ -543,6 +549,7 @@ const publishOne = async (input: {
|
||||
result: await input.session.append(
|
||||
failureRecord(input.procedure, body.fact, reason, {
|
||||
limitations: caveats,
|
||||
providerSource: observationSource,
|
||||
}),
|
||||
),
|
||||
bodySupport: body.support,
|
||||
@@ -580,6 +587,7 @@ const publishOne = async (input: {
|
||||
failureRecord(input.procedure, body.fact, reason, {
|
||||
relativePath,
|
||||
limitations: caveats,
|
||||
providerSource: observationSource,
|
||||
}),
|
||||
),
|
||||
bodySupport: body.support,
|
||||
@@ -591,6 +599,11 @@ const publishOne = async (input: {
|
||||
status: "written",
|
||||
relative_path: relativePath,
|
||||
sha256: committed.sha256,
|
||||
// Matching existing bytes prove equality, not who originally wrote them.
|
||||
provider_source:
|
||||
committed.status === "matched"
|
||||
? { status: "unknown", reason: "existing_file_origin_unknown" }
|
||||
: observationSource,
|
||||
body: bodyRecord(body.fact),
|
||||
// The header may truncate; the journal keeps the complete list.
|
||||
...(caveats.length === 0 ? {} : { limitations: caveats }),
|
||||
@@ -889,6 +902,7 @@ const failureRecord = (
|
||||
readonly relativePath?: string;
|
||||
/** Caveats of the observations this attempt used. */
|
||||
readonly limitations?: readonly string[];
|
||||
readonly providerSource?: DecompileDirectoryFunctionRecord["provider_source"];
|
||||
} = {},
|
||||
): DecompileDirectoryFunctionRecord => ({
|
||||
address: procedure.address,
|
||||
@@ -896,6 +910,9 @@ const failureRecord = (
|
||||
status: "failed",
|
||||
body: bodyRecord(body),
|
||||
failure: reason,
|
||||
...(options.providerSource === undefined
|
||||
? {}
|
||||
: { provider_source: options.providerSource }),
|
||||
...(options.relativePath === undefined
|
||||
? {}
|
||||
: { relative_path: options.relativePath }),
|
||||
|
||||
@@ -5,10 +5,7 @@ import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
|
||||
import { InvestigationRecords } from "./InvestigationRecords.js";
|
||||
import {
|
||||
createInvestigationEvidenceClosureChecker,
|
||||
resolveInvestigationEvidenceClosure,
|
||||
} from "./InvestigationEvidence.js";
|
||||
import { resolveInvestigationEvidenceClosure } from "./InvestigationEvidence.js";
|
||||
|
||||
const observation = (path: string, links: readonly string[] = []): Evidence =>
|
||||
createEvidence(
|
||||
@@ -111,50 +108,3 @@ it("rejects a lookup that returns another semantic Evidence identity", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves an authenticated source with a wide repeated-link array", () => {
|
||||
const leaf = observation("/wide-leaf");
|
||||
const source = observation(
|
||||
"/wide-source",
|
||||
Array(500_000).fill(leaf.evidence_id),
|
||||
);
|
||||
expect(source.evidence_links).toHaveLength(500_000);
|
||||
const records = new InvestigationRecords();
|
||||
for (const record of [leaf, source]) {
|
||||
const added = records.recordEvidence(record);
|
||||
if (!added.ok) throw added.error;
|
||||
}
|
||||
expect(
|
||||
records.evidenceForAnalysis(source.evidence_id)?.evidence_links,
|
||||
).toHaveLength(500_000);
|
||||
expect(
|
||||
resolveInvestigationEvidenceClosure(records, [source.evidence_id]),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: [source.evidence_id, leaf.evidence_id].sort(),
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses a complete shared source graph without weakening missing-source checks", () => {
|
||||
const leaf = observation("/shared-leaf");
|
||||
const source = observation("/shared-source", [leaf.evidence_id]);
|
||||
const records = new Map(
|
||||
[leaf, source].map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
let lookups = 0;
|
||||
const check = createInvestigationEvidenceClosureChecker({
|
||||
evidenceForAnalysis: (id) => {
|
||||
lookups++;
|
||||
return records.get(id);
|
||||
},
|
||||
});
|
||||
expect(check([source.evidence_id])).toBe(true);
|
||||
expect(lookups).toBe(2);
|
||||
expect(check([source.evidence_id, leaf.evidence_id])).toBe(true);
|
||||
expect(lookups).toBe(2);
|
||||
const missing = `ev_${"0".repeat(64)}`;
|
||||
expect(check([missing])).toBe(false);
|
||||
expect(check([missing])).toBe(false);
|
||||
expect(lookups).toBe(3);
|
||||
expect(check([source.evidence_id, missing])).toBe(false);
|
||||
});
|
||||
@@ -5,37 +5,6 @@ import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
|
||||
type Lookup = (id: string) => Evidence | undefined;
|
||||
|
||||
/** Traverse without expanding a wide link array into function arguments. */
|
||||
const visitClosure = (
|
||||
lookup: Lookup,
|
||||
seedIds: readonly string[],
|
||||
knownComplete?: ReadonlySet<string>,
|
||||
): Result<Set<string>, EvidenceIntegrityError> => {
|
||||
const visited = new Set<string>();
|
||||
const pending = [...seedIds];
|
||||
while (pending.length > 0) {
|
||||
const evidenceId = pending.pop();
|
||||
if (
|
||||
evidenceId === undefined ||
|
||||
visited.has(evidenceId) ||
|
||||
knownComplete?.has(evidenceId)
|
||||
)
|
||||
continue;
|
||||
const evidence = resolveEvidenceInput(
|
||||
{ kind: "retained-evidence", evidence_id: evidenceId },
|
||||
lookup,
|
||||
"retained-closure",
|
||||
);
|
||||
if (!evidence.ok) return evidence;
|
||||
visited.add(evidenceId);
|
||||
for (const linkedId of evidence.value.evidence_links)
|
||||
pending.push(linkedId);
|
||||
}
|
||||
return ok(visited);
|
||||
};
|
||||
|
||||
/** Follow exact session references without exporting unrelated records or Unknowns. */
|
||||
export const resolveInvestigationEvidenceClosure = (
|
||||
reader: Pick<EvidenceReader, "evidenceForAnalysis">,
|
||||
@@ -47,32 +16,21 @@ export const resolveInvestigationEvidenceClosure = (
|
||||
const supplied = new Map(
|
||||
sources.map((source) => [source.evidence_id, source]),
|
||||
);
|
||||
const visited = visitClosure(
|
||||
(id) => supplied.get(id) ?? reader.evidenceForAnalysis(id),
|
||||
seedIds,
|
||||
);
|
||||
return visited.ok ? ok(uniqueSorted([...visited.value])) : visited;
|
||||
};
|
||||
|
||||
/** Reuse closure decisions while the caller's lookup remains immutable. */
|
||||
export const createInvestigationEvidenceClosureChecker = (
|
||||
reader: Pick<EvidenceReader, "evidenceForAnalysis">,
|
||||
): ((seedIds: readonly string[]) => boolean) => {
|
||||
const complete = new Set<string>();
|
||||
const incomplete = new Set<string>();
|
||||
return (seedIds) => {
|
||||
const single = seedIds.length === 1 ? seedIds[0] : undefined;
|
||||
if (single !== undefined && incomplete.has(single)) return false;
|
||||
const visited = visitClosure(
|
||||
(id) => reader.evidenceForAnalysis(id),
|
||||
seedIds,
|
||||
complete,
|
||||
const lookup = (id: string) =>
|
||||
supplied.get(id) ?? reader.evidenceForAnalysis(id);
|
||||
const visited = new Set<string>();
|
||||
const pending = [...seedIds];
|
||||
while (pending.length > 0) {
|
||||
const evidenceId = pending.pop();
|
||||
if (evidenceId === undefined || visited.has(evidenceId)) continue;
|
||||
const evidence = resolveEvidenceInput(
|
||||
{ kind: "retained-evidence", evidence_id: evidenceId },
|
||||
lookup,
|
||||
"retained-closure",
|
||||
);
|
||||
if (!visited.ok) {
|
||||
if (single !== undefined) incomplete.add(single);
|
||||
return false;
|
||||
}
|
||||
for (const id of visited.value) complete.add(id);
|
||||
return true;
|
||||
};
|
||||
if (!evidence.ok) return evidence;
|
||||
visited.add(evidenceId);
|
||||
pending.push(...evidence.value.evidence_links);
|
||||
}
|
||||
return ok(uniqueSorted([...visited]));
|
||||
};
|
||||
@@ -5,10 +5,7 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
/** Source facts remain available even when a completed observation is cancelled. */
|
||||
export type AnalysisResultWithSources<Value, Failure = AnalysisError> = Result<
|
||||
Value,
|
||||
Failure
|
||||
> & {
|
||||
export type AnalysisResultWithSources<Value> = Result<Value, AnalysisError> & {
|
||||
readonly sourceEvidence?: readonly Evidence[];
|
||||
};
|
||||
|
||||
|
||||
@@ -549,7 +549,7 @@ export class ReverseEngineeringService {
|
||||
? "REA_OBJDUMP_COMMAND"
|
||||
: "REA_RIZIN_COMMAND";
|
||||
earlyFailure = new AnalysisCapabilityUnavailableError(
|
||||
profile.identity.id,
|
||||
provider,
|
||||
operation,
|
||||
`Unable to run configured command (${String(code ?? "unknown error")})`,
|
||||
{
|
||||
@@ -708,10 +708,10 @@ export class ReverseEngineeringService {
|
||||
const limitations = [
|
||||
...(absolutePath === undefined
|
||||
? [
|
||||
"The objdump implementation and version are unknown. The captured output is the selected command's response and does not establish support for an artifact.",
|
||||
"The objdump version is not reported; the output lists BFD targets advertised by the installed tool and does not establish support for the selected artifact.",
|
||||
]
|
||||
: [
|
||||
"The executable implementation, version and supported formats are unknown because no reliable identity probe was performed.",
|
||||
"The executable version and supported formats are reported as unknown because no reliable version probe was performed.",
|
||||
]),
|
||||
"A non-zero process exit is preserved as an observed command result.",
|
||||
...(operation === "inspect_with_objdump" &&
|
||||
@@ -759,7 +759,9 @@ export class ReverseEngineeringService {
|
||||
`Command completion is unknown because the operation failed with ${failure._tag}: ${failure.message}`,
|
||||
]),
|
||||
...(absolutePath === undefined
|
||||
? []
|
||||
? [
|
||||
"This output lists BFD targets advertised by the installed objdump and is independent of the selected artifact.",
|
||||
]
|
||||
: [
|
||||
"When both are available, pre-run and post-run digests detect artifact changes across the provider call but cannot prove the exact bytes consumed during the call.",
|
||||
]),
|
||||
|
||||
@@ -36,8 +36,7 @@ export const DMG_EXISTING_ATTACHMENT_RECOVERY =
|
||||
"hdiutil refused a private read-only DMG attachment after listing an existing attachment of this image path. If it is still attached, ask its owner to release it when safe, then retry. REA does not borrow or detach existing attachments.";
|
||||
const DMG_CHECKSUM_ABSENT_LIMITATION =
|
||||
"hdiutil verify found no stored checksum in this disk image, so its integrity was not verified; the root SHA-256 identifies the inventoried bytes.";
|
||||
const VERIFY_TRANSIENT_DELAY_MS = 250;
|
||||
const VERIFY_UNKNOWN_SETTLE_DELAYS_MS = [250, 500] as const;
|
||||
const VERIFY_SETTLE_DELAYS_MS = [250, 500] as const;
|
||||
// APFS ejects and busy reporting can lag a detach attempt.
|
||||
const DETACH_SETTLE_DELAYS_MS = [250, 500, 1000] as const;
|
||||
const delay = (milliseconds: number): Promise<void> =>
|
||||
@@ -546,13 +545,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
},
|
||||
);
|
||||
}
|
||||
// An explicit resource refusal can outlast the initial settle window
|
||||
// after image creation. Its retries share the existing command deadline;
|
||||
// an ambiguous localized refusal keeps only the short diagnostic probe.
|
||||
const delayMs =
|
||||
cause.verifyDiagnostic === "Resource temporarily unavailable"
|
||||
? VERIFY_TRANSIENT_DELAY_MS
|
||||
: VERIFY_UNKNOWN_SETTLE_DELAYS_MS[attempt];
|
||||
const delayMs = VERIFY_SETTLE_DELAYS_MS[attempt];
|
||||
// Retry only the producer's explicit transient diagnostic, or a localized
|
||||
// refusal of a readable target that may be the same, before any mount.
|
||||
if (
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import type { ZodSafeParseResult } from "zod";
|
||||
import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js";
|
||||
import type { AnalysisResultWithSources } from "../application/investigation/recordEvidenceSources.js";
|
||||
|
||||
export const analysisEvidenceDeliveryOptions = {
|
||||
detail: z
|
||||
@@ -204,16 +203,9 @@ const withUndeliveredSourceClosure = (
|
||||
|
||||
/** Publish already finalized CLI analysis without opening a second provider/session. */
|
||||
export const publishCliAnalysisEvidence = async (
|
||||
result: AnalysisResultWithSources<Evidence, JsonValue>,
|
||||
result: Result<Evidence, JsonValue>,
|
||||
input: CliEvidenceDelivery,
|
||||
): Promise<JsonValue> => {
|
||||
input = {
|
||||
...input,
|
||||
sourceEvidence: [
|
||||
...(input.sourceEvidence ?? []),
|
||||
...(result.sourceEvidence ?? []),
|
||||
],
|
||||
};
|
||||
const { operation, signal, detail, evidenceOutput } = input;
|
||||
const projectFailure = (error: AnalysisError) =>
|
||||
projectAnalysisError(
|
||||
|
||||
@@ -36,6 +36,7 @@ import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { registerDecompileDirectoryCommand } from "./decompileDirectoryCommand.js";
|
||||
import { decompileAnalysisSelection } from "../contracts/officialToolContracts.js";
|
||||
|
||||
const decompileSelectorGuidance = `${addressOrProcedureGuidance} Alternatively, use --file-offset alone.`;
|
||||
const namedAddressGuidance = requiredSelectorGuidance(
|
||||
@@ -221,6 +222,7 @@ const registerDecompileCommand = (
|
||||
.describe(
|
||||
`Ghidra source-file byte offset; mapped internally to an analysis address. ${decompileSelectorGuidance}`,
|
||||
),
|
||||
analysis: decompileAnalysisSelection,
|
||||
snapshot: snapshotSelectionOption,
|
||||
...nativeOpenOptionFields,
|
||||
}),
|
||||
@@ -245,6 +247,9 @@ const registerDecompileCommand = (
|
||||
: "file-offset",
|
||||
"procedure_pseudo_code",
|
||||
),
|
||||
...(options.analysis === undefined
|
||||
? {}
|
||||
: { analysis: options.analysis }),
|
||||
},
|
||||
directAnalysisOptionsFromCli(logger, options),
|
||||
).then(projectCliAnalysisResult),
|
||||
@@ -357,41 +362,24 @@ const registerTraceCommand = (
|
||||
.default(false)
|
||||
.describe("Match the query with exact letter case"),
|
||||
snapshot: snapshotSelectionOption,
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
...nativeOpenOptionFields,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, "trace", async () => {
|
||||
const delivery = {
|
||||
providerId: "auto",
|
||||
operation: "trace_feature",
|
||||
signal,
|
||||
detail: options.detail,
|
||||
evidenceOutput: options.evidence_output,
|
||||
};
|
||||
const inputError = cliEvidenceDeliveryInputError(delivery);
|
||||
if (inputError !== undefined) return projectAnalysisError(inputError);
|
||||
const complete = await runDirectAnalysis(
|
||||
args.path,
|
||||
"trace_feature",
|
||||
{
|
||||
query: resolveCliAnalysisSelector(
|
||||
args.query,
|
||||
options.query,
|
||||
"query",
|
||||
"trace_feature",
|
||||
),
|
||||
case_sensitive: options.caseSensitive,
|
||||
},
|
||||
{
|
||||
...directAnalysisOptionsFromCli(logger, options),
|
||||
signal,
|
||||
includeEvidenceSources: options.evidence_output !== undefined,
|
||||
},
|
||||
);
|
||||
return publishCliAnalysisEvidence(complete, delivery);
|
||||
}),
|
||||
logCliCommand(logger, "trace", () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"trace_feature",
|
||||
{
|
||||
query: resolveCliAnalysisSelector(
|
||||
args.query,
|
||||
options.query,
|
||||
"query",
|
||||
"trace_feature",
|
||||
),
|
||||
case_sensitive: options.caseSensitive,
|
||||
},
|
||||
directAnalysisOptionsFromCli(logger, options),
|
||||
).then(projectCliAnalysisResult),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -447,11 +435,7 @@ const registerFunctionCommand = (
|
||||
"analyze_function",
|
||||
),
|
||||
},
|
||||
{
|
||||
...directAnalysisOptionsFromCli(logger, options),
|
||||
signal,
|
||||
includeEvidenceSources: options.evidence_output !== undefined,
|
||||
},
|
||||
{ ...directAnalysisOptionsFromCli(logger, options), signal },
|
||||
);
|
||||
return publishCliAnalysisEvidence(complete, delivery);
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "incur";
|
||||
import type { Logger } from "pino";
|
||||
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
|
||||
|
||||
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -193,7 +192,7 @@ export const registerReverseEngineeringCommands = (
|
||||
const service = createService(environment);
|
||||
cli.command(CLI_COMMANDS.inspectWithObjdump, {
|
||||
description:
|
||||
"Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence",
|
||||
"Inspect a local artifact with GNU objdump and retain its raw output as Evidence",
|
||||
args: z.object({
|
||||
path: z
|
||||
.string()
|
||||
@@ -201,7 +200,7 @@ export const registerReverseEngineeringCommands = (
|
||||
.describe("Path to the local artifact to inspect."),
|
||||
operation: z
|
||||
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
|
||||
.describe("GNU-style objdump operation to run."),
|
||||
.describe("GNU objdump inspection operation to run."),
|
||||
}),
|
||||
options: z.object({
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
@@ -209,7 +208,7 @@ export const registerReverseEngineeringCommands = (
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Native symbol to select with the disassemble operation; the selected executable controls matching and boundaries.",
|
||||
"Native symbol to select with the disassemble operation; GNU objdump controls matching and boundaries.",
|
||||
),
|
||||
followDebugLinks: z
|
||||
.boolean()
|
||||
@@ -223,7 +222,7 @@ export const registerReverseEngineeringCommands = (
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => {
|
||||
const delivery = {
|
||||
providerId: OBJDUMP_PROVIDER_IDENTITY.id,
|
||||
providerId: "gnu.objdump",
|
||||
operation: "inspect_with_objdump",
|
||||
signal,
|
||||
detail: options.detail,
|
||||
@@ -256,7 +255,7 @@ export const registerReverseEngineeringCommands = (
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
|
||||
description: "Capture the selected objdump command response to -i",
|
||||
description: "List BFD targets advertised by the installed GNU objdump",
|
||||
args: z.object({}),
|
||||
options: z.object({
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
@@ -269,7 +268,7 @@ export const registerReverseEngineeringCommands = (
|
||||
CLI_COMMANDS.listObjdumpArchitectures,
|
||||
async () => {
|
||||
const delivery = {
|
||||
providerId: OBJDUMP_PROVIDER_IDENTITY.id,
|
||||
providerId: "gnu.objdump",
|
||||
operation: "inspect_with_objdump",
|
||||
signal,
|
||||
detail: options.detail,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redactLine truncated
|
||||
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the fLine truncated
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import { digestSchema } from "../domain/digests.js";
|
||||
import { absoluteLocalPathStringSchema } from "../domain/localPath.js";
|
||||
import { analysisSourceDescriptorSchema } from "../domain/analysisSource.js";
|
||||
|
||||
/** On-disk journal. Append-only so a crash keeps committed function files. */
|
||||
export const DECOMPILE_DIRECTORY_JOURNAL_NAME = "decompile-journal.jsonl";
|
||||
@@ -245,6 +246,22 @@ export const decompileDirectoryFunctionSchema = z
|
||||
)
|
||||
.optional(),
|
||||
sha256: digestSchema.optional(),
|
||||
/** Source of this attempt's pseudocode observation; a matched file's original producer remains unknown. */
|
||||
provider_source: z
|
||||
.discriminatedUnion("status", [
|
||||
z.strictObject({
|
||||
status: z.literal("reported"),
|
||||
source: analysisSourceDescriptorSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
status: z.literal("unknown"),
|
||||
reason: z.enum([
|
||||
"source_not_available",
|
||||
"existing_file_origin_unknown",
|
||||
]),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
body: bodySchema,
|
||||
failure: z
|
||||
.strictObject({
|
||||
|
||||
@@ -82,8 +82,8 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
||||
...FUNCTION_WORKFLOW_TOOL_CONTRACTS,
|
||||
enhanced(
|
||||
"trace_feature",
|
||||
"Trace the complete literal query, preserving spaces, through every matching string and procedure and their xrefs. Preserve an explicit unavailable owner for a non-retryable containment capability refusal. Returns observations and residual unknowns without inferring reference kinds; truncated only reports an observed reference cap. For a procedure match, use procedure_references with direction incoming for provider-reported kinds when supported; it covers the procedure body, so compare target addresses when selecting exact-entry references. Select detail summary to retain complete Evidence and page matches, references and unknowns with inspect_analysis_view kind native-feature-trace.",
|
||||
enhancedInputSchemas.trace_feature.extend({ detail: analysisDetailSchema }),
|
||||
"Trace the complete literal query, preserving spaces, through every matching string and procedure and their xrefs. Preserve an explicit unavailable owner for a non-retryable containment capability refusal. Returns observations and residual unknowns without inferring reference kinds; truncated only reports an observed reference cap.",
|
||||
enhancedInputSchemas.trace_feature,
|
||||
),
|
||||
enhanced(
|
||||
"trace_call_path",
|
||||
|
||||
@@ -111,7 +111,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_elf_notes"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Inspect one explicit local ELF32/ELF64 image's PT_NOTE program headers and SHT_NOTE sections without executing it or opening a disassembler. Returns note-table file ranges, owners, types, descriptor SHA-256 and hex previews, plus GNU build-id, ABI-tag and gold-version fields and Linux CORE process, mapping, signal, and auxv notes when those descriptors match. A missing table is observed absence of those declarations, not proof the image never carried notes in another container. Reads a stable regular file; malformed ELF headers, truncated notes and exhausted budgets return no partial success. Portable on supported Node hosts; no debug package or catalog is required.",
|
||||
"Inspect one explicit local ELF32/ELF64 image's PT_NOTE program headers and SHT_NOTE sections without executing it or opening a disassembler. Returns note-table file ranges, owners, types, descriptor SHA-256 and hex previews, plus GNU build-id, ABI-tag and gold-version fields when those descriptors match. A missing table is observed absence of those declarations, not proof the image never carried notes in another container. Reads a stable regular file; malformed ELF headers, truncated notes and exhausted budgets return no partial success. Portable on supported Node hosts; no debug package or catalog is required.",
|
||||
inputSchema: inspectElfNotesInputSchema,
|
||||
outputSchema: evidenceResultOf(elfNotesSchema),
|
||||
examples: [
|
||||
|
||||
@@ -23,6 +23,13 @@ import { nativeSearchInput } from "../domain/native/nativeSearch.js";
|
||||
const hopperRegexDeadline =
|
||||
"Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker. The matching deadline is REA_REGEX_MATCHING_DEADLINE_MS (a positive decimal integer of milliseconds from 1 to 2147483647, default 5000). Unset keeps the default. A deadline failure names the effective milliseconds, source (default or setting), the default, and the maximum, and leaves the API usable. The worker old-generation request is REA_REGEX_WORKER_HEAP_REQUEST_MIB (a decimal integer of mebibytes from 7 to 16384, default 128). Unset keeps the default. A positive Node --max-old-space-size, including NODE_OPTIONS, overrides that request and is not limited by the setting's floor or maximum. A heap failure names the effective request, its source (default or setting), the default, the floor, and the maximum.";
|
||||
|
||||
export const decompileAnalysisSelection = z
|
||||
.enum(["independent_fresh"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Request a new independent decompile, bypassing project and snapshot results. Use only when the routed provider's procedure_pseudo_code capability declares independent_decompile; otherwise the request is refused. This may use different analysis context.",
|
||||
);
|
||||
|
||||
const official = <
|
||||
Name extends keyof typeof officialOutputSchemas,
|
||||
Schema extends z.ZodObject,
|
||||
@@ -207,8 +214,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"procedure_pseudo_code",
|
||||
"Decompile one analyzed procedure by symbol name or provider-normalized address. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities.",
|
||||
z.object({ procedure, document }),
|
||||
"Decompile one analyzed procedure by symbol name or provider-normalized address. To request a fresh independent attempt, use analysis: independent_fresh when binary_session reports independent_decompile for this provider operation; otherwise it is refused. The independent result may have different analysis context. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities.",
|
||||
z.object({
|
||||
procedure,
|
||||
document,
|
||||
analysis: decompileAnalysisSelection,
|
||||
}),
|
||||
),
|
||||
official(
|
||||
"resolve_containing_procedure",
|
||||
@@ -224,7 +235,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"search_strings",
|
||||
`Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. For known encoded bytes, Ghidra search_bytes can find byte matches; a hit does not establish a string or use. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`,
|
||||
`Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`,
|
||||
z.object({ ...nativeSearchInput, detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
|
||||
@@ -17,7 +17,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_with_objdump"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run a caller-selected objdump command using GNU-style arguments against a local artifact, or request operation=architectures without a path to capture its response to -i. The executable implementation and version are not verified; other implementations may differ in options and output. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow the selected executable. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Default detail complete returns original Evidence. Explicit detail summary retains the complete parent and returns its command observation once, without the raw mirror; parent_evidence_id supports later inspect_analysis_view summary queries and complete Evidence export. Architecture output is subjectless and does not establish support for any artifact. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap.",
|
||||
"Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow GNU objdump. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Default detail complete returns original Evidence. Explicit detail summary retains the complete parent and returns its command observation once, without the raw mirror; parent_evidence_id supports later inspect_analysis_view summary queries and complete Evidence export. Architecture output is subjectless and does not establish support for any artifact. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap.",
|
||||
inputSchema: objdumpInputSchema,
|
||||
outputSchema: analysisDetailOutputSchemaOf(
|
||||
commandObservationSchema,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
),
|
||||
session(
|
||||
"import_evidence_bundle",
|
||||
"Read the JSON bundle at the supplied local path, validate every Evidence ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed. Import restores registry entries present in the bundle; questions embedded in Evidence do not automatically become Unknown registry entries.",
|
||||
"Read the JSON bundle at the supplied local path, validate every Evidence ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed.",
|
||||
importEvidenceBundleInputSchema,
|
||||
),
|
||||
session(
|
||||
@@ -153,7 +153,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
),
|
||||
session(
|
||||
"list_unknowns",
|
||||
"List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. Results are complete inline by default; supply offset and limit to page the filtered order. total is the matching-head count and next_offset is null when the page exhausts the result. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct. An empty registry does not mean retained Evidence has no residual questions; inspect the relevant Evidence or its analysis view.",
|
||||
"List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. Results are complete inline by default; supply offset and limit to page the filtered order. total is the matching-head count and next_offset is null when the page exhausts the result. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.",
|
||||
listUnknownsInputSchema,
|
||||
),
|
||||
session(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
nativeAddressListSchema,
|
||||
nativeObservationSourceSchemas,
|
||||
traceContainingProcedureSchema,
|
||||
} from "../domain/native/nativeObservation.js";
|
||||
import { overviewInventoryCoverageSchema } from "../domain/native/inventoryCoverage.js";
|
||||
import {
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
nativeFunctionMatchingSummaryViewResultSchema,
|
||||
nativeFunctionSummaryViewResultSchema,
|
||||
nativeInventorySummaryViewResultSchema,
|
||||
nativeFeatureTraceSummaryViewResultSchema,
|
||||
batchDecompileSummaryViewResultSchema,
|
||||
nativeLoadImageSummaryViewResultSchema,
|
||||
artifactInventorySummaryViewResultSchema,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
managedArtifactSummaryViewResultSchema,
|
||||
managedMemberSummaryViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
import { literalFeatureTraceSchema } from "../domain/native/literalFeatureTrace.js";
|
||||
import { literalFeatureMatchSchema } from "../domain/native/literalFeatureMatch.js";
|
||||
import { byteEntropySchema } from "../domain/native/byteEntropy.js";
|
||||
import { nativeFunctionMatchingSchema } from "../domain/native/functionMatching.js";
|
||||
import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js";
|
||||
@@ -263,9 +263,21 @@ export const officialOutputSchemas = {
|
||||
xrefs: evidenceResultOf(nativeAddressListSchema),
|
||||
} as const satisfies Readonly<Record<string, z.ZodObject>>;
|
||||
|
||||
const literalTraceOutput = analysisDetailOutputSchemaOf(
|
||||
literalFeatureTraceSchema,
|
||||
nativeFeatureTraceSummaryViewResultSchema,
|
||||
const literalTraceOutput = evidenceResultOf(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
search_mode: z.literal("literal"),
|
||||
matches: z.array(literalFeatureMatchSchema),
|
||||
references: z.array(
|
||||
z.object({
|
||||
target_address: z.string(),
|
||||
source_address: z.string(),
|
||||
containing_procedure: traceContainingProcedureSchema,
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean(),
|
||||
residual_unknowns: z.array(z.string()),
|
||||
}),
|
||||
);
|
||||
|
||||
const callPathTraceOutput = evidenceResultOf(
|
||||
|
||||
@@ -65,6 +65,7 @@ const providerCapabilityFacts = {
|
||||
requires_root: z.boolean(),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
independent_decompile: z.literal(true).exactOptional(),
|
||||
};
|
||||
|
||||
export const providerCapability = z.discriminatedUnion("available", [
|
||||
@@ -99,6 +100,7 @@ const providerCapabilityGroupFacts = {
|
||||
operations: z.array(z.string().min(1)).min(1),
|
||||
effects: z.array(providerCapabilityFacts.effects.keyof()),
|
||||
limitations: z.array(z.string()),
|
||||
independent_decompile: providerCapabilityFacts.independent_decompile,
|
||||
};
|
||||
|
||||
const providerCapabilityGroup = z.discriminatedUnion("available", [
|
||||
@@ -136,6 +138,9 @@ export const expandProviderCapabilities = (
|
||||
group.operations.map((operation) => ({
|
||||
...providerCapability.parse({
|
||||
operation,
|
||||
...(group.independent_decompile === undefined
|
||||
? {}
|
||||
: { independent_decompile: group.independent_decompile }),
|
||||
available: group.available,
|
||||
reason: group.reason,
|
||||
availability_code: group.availability_code,
|
||||
|
||||
@@ -462,7 +462,7 @@ describe("analysis snapshot canonical ordering and scope", () => {
|
||||
foreign,
|
||||
]),
|
||||
}),
|
||||
).toThrow(/unrelated to the selected target/u);
|
||||
).toThrow(/another target/u);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,61 +511,6 @@ it("does not select arbitrary provenance when multiple authenticated records mat
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retains linked foreign Evidence without treating it as selected-target replay", () => {
|
||||
const { current, snapshot } = snapshotWithHistoricalEvidence();
|
||||
const foreignTarget = { ...ANALYSIS_SNAPSHOT_TARGET, sha256: "b".repeat(64) };
|
||||
const foreign = createEvidence(foreignTarget, ANALYSIS_SNAPSHOT_PROVIDER, {
|
||||
operation: current.operation,
|
||||
parameters: current.parameters,
|
||||
result: current.normalized_result,
|
||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
});
|
||||
const linked = createEvidence(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{
|
||||
operation: current.operation,
|
||||
parameters: current.parameters,
|
||||
result: current.normalized_result,
|
||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
evidenceLinks: [foreign.evidence_id],
|
||||
},
|
||||
);
|
||||
const bundle = createEvidenceBundle([
|
||||
...snapshot.evidence_bundle.records.filter(
|
||||
(record) => record.evidence_id !== current.evidence_id,
|
||||
),
|
||||
linked,
|
||||
foreign,
|
||||
]);
|
||||
const admitted = parseAnalysisSnapshot({
|
||||
...snapshot,
|
||||
evidence_bundle: bundle,
|
||||
});
|
||||
expect(
|
||||
snapshotEvidenceForQuery(admitted, {
|
||||
target: ANALYSIS_SNAPSHOT_TARGET,
|
||||
bindingProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
operation: linked.operation,
|
||||
parameters: linked.parameters,
|
||||
provider: ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
evidenceProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
}),
|
||||
).toEqual(linked);
|
||||
expect(snapshotMatchesTarget(admitted.target, foreignTarget)).toBe(false);
|
||||
const unrelated = createEvidence(foreignTarget, ANALYSIS_SNAPSHOT_PROVIDER, {
|
||||
operation: "unrelated",
|
||||
parameters: {},
|
||||
result: "foreign",
|
||||
});
|
||||
expect(() =>
|
||||
parseAnalysisSnapshot({
|
||||
...snapshot,
|
||||
evidence_bundle: createEvidenceBundle([...bundle.records, unrelated]),
|
||||
}),
|
||||
).toThrow("unrelated to the selected target and its linked sources");
|
||||
});
|
||||
|
||||
describe("analysis snapshot Evidence binding", () => {
|
||||
it("finds only Evidence committed to the exact binding and profile", () => {
|
||||
const { current, snapshot } = snapshotWithHistoricalEvidence();
|
||||
|
||||
@@ -738,7 +738,7 @@ const restoreSnapshot = (
|
||||
scopedEvidence.unknowns.length !== bundle.unknowns.length
|
||||
)
|
||||
throw new TypeError(
|
||||
"Analysis snapshot evidence contains records unrelated to the selected target and its linked sources",
|
||||
"Analysis snapshot evidence contains records for another target",
|
||||
);
|
||||
if (!hasCanonicalQueryOrder(providerRows.map(({ entry }) => entry)))
|
||||
throw new TypeError("Analysis snapshot entries are not canonical");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "./digests.js";
|
||||
import { absoluteLocalPathStringSchema } from "./localPath.js";
|
||||
|
||||
/** Compact source facts supplied by the provider for one observation. */
|
||||
export const analysisSourceDescriptorSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("file-range"),
|
||||
path: absoluteLocalPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
byte_offset: z.int().nonnegative(),
|
||||
byte_length: z.int().nonnegative(),
|
||||
}),
|
||||
z.strictObject({ kind: z.literal("single-function-decompile") }),
|
||||
]);
|
||||
|
||||
export type AnalysisSourceDescriptor = z.infer<
|
||||
typeof analysisSourceDescriptorSchema
|
||||
>;
|
||||
@@ -18,7 +18,6 @@ import { projectJavaScriptRoutesView } from "./javascriptRoutesView.js";
|
||||
import { projectWebBundleView } from "./webBundleView.js";
|
||||
import { projectSourceToBundleView } from "./sourceToBundleView.js";
|
||||
import { projectFeatureTraceView } from "./featureTraceView.js";
|
||||
import { projectNativeFeatureTraceView } from "./nativeFeatureTraceView.js";
|
||||
import { projectBatchDecompileView } from "./batchDecompileView.js";
|
||||
import { projectGpuCodeObjectsView } from "./gpuCodeObjectsView.js";
|
||||
import { projectModelArtifactView } from "./modelArtifactView.js";
|
||||
@@ -145,7 +144,6 @@ import {
|
||||
modelArtifactPageViewResultSchema,
|
||||
il2cppMetadataSummaryViewResultSchema,
|
||||
nativeInventorySummaryViewResultSchema,
|
||||
nativeFeatureTraceSummaryViewResultSchema,
|
||||
nativeLoadImageSummaryViewResultSchema,
|
||||
nativeFunctionMatchingSummaryViewResultSchema,
|
||||
nativeFunctionSummaryViewResultSchema,
|
||||
@@ -167,7 +165,6 @@ export {
|
||||
};
|
||||
export {
|
||||
nativeObservationSummaryViewResultSchemas,
|
||||
nativeFeatureTraceSummaryViewResultSchema,
|
||||
byteEntropySummaryViewResultSchema,
|
||||
nativeExportsSummaryViewResultSchema,
|
||||
devicetreeSummaryViewResultSchema,
|
||||
@@ -303,7 +300,6 @@ const retain = (
|
||||
): readonly [string, RetainedViewProjector] => [operation, project];
|
||||
|
||||
const retainedViewProjectors = new Map<string, RetainedViewProjector>([
|
||||
retain("trace_feature", projectNativeFeatureTraceView),
|
||||
...NATIVE_OBSERVATION_OPERATIONS.map((operation) =>
|
||||
retain(operation, projectNativeObservationView),
|
||||
),
|
||||
@@ -440,7 +436,6 @@ export const projectAnalysisView = (
|
||||
parent.operation === "match_native_functions"
|
||||
? NATIVE_FUNCTION_MATCHING_PREDICATE
|
||||
: parent.operation === "analyze_function" ||
|
||||
parent.operation === "trace_feature" ||
|
||||
parent.operation === "batch_decompile" ||
|
||||
isNativeObservationOperation(parent.operation) ||
|
||||
isNativeInventoryOperation(parent.operation)
|
||||
|
||||
@@ -11,7 +11,6 @@ import { javaScriptRoutesViewRequestSchema } from "./javascriptRoutesView.js";
|
||||
import { webBundleViewRequestSchema } from "./webBundleView.js";
|
||||
import { sourceToBundleViewRequestSchema } from "./sourceToBundleView.js";
|
||||
import { featureTraceViewRequestSchema } from "./featureTraceView.js";
|
||||
import { nativeFeatureTraceViewRequestSchema } from "./nativeFeatureTraceView.js";
|
||||
import { batchDecompileViewRequestSchema } from "./batchDecompileView.js";
|
||||
import { gpuCodeObjectsViewRequestSchema } from "./gpuCodeObjectsView.js";
|
||||
import { modelArtifactViewRequestSchema } from "../model/modelArtifactQuery.js";
|
||||
@@ -70,7 +69,6 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
nativeFunctionMatchingViewRequestSchema,
|
||||
sourceToBundleViewRequestSchema,
|
||||
featureTraceViewRequestSchema,
|
||||
nativeFeatureTraceViewRequestSchema,
|
||||
batchDecompileViewRequestSchema,
|
||||
summaryViewSchema,
|
||||
goBinaryViewRequestSchema,
|
||||
|
||||
@@ -68,11 +68,6 @@ import {
|
||||
featureTraceViewCollections,
|
||||
featureTraceViewContextSchema,
|
||||
} from "./featureTraceView.js";
|
||||
import {
|
||||
nativeFeatureTraceSummarySchema,
|
||||
nativeFeatureTraceViewItems,
|
||||
nativeFeatureTraceRequests,
|
||||
} from "./nativeFeatureTraceView.js";
|
||||
import {
|
||||
batchDecompileSummarySchema,
|
||||
batchDecompileViewCollections,
|
||||
@@ -838,40 +833,6 @@ export const nativeInventorySummaryViewResultSchema = z.strictObject({
|
||||
summary: nativeInventorySummarySchema,
|
||||
});
|
||||
|
||||
export const nativeFeatureTraceSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
...nativeViewSourceContext,
|
||||
artifact: artifactSchema.nullable(),
|
||||
parent_operation: z.literal("trace_feature"),
|
||||
summary: nativeFeatureTraceSummarySchema,
|
||||
});
|
||||
|
||||
const nativeFeatureTracePage = <
|
||||
Collection extends keyof typeof nativeFeatureTraceViewItems,
|
||||
>(
|
||||
collection: Collection,
|
||||
) =>
|
||||
z.strictObject({
|
||||
...viewResultBase,
|
||||
...nativeViewSourceContext,
|
||||
artifact: artifactSchema.nullable(),
|
||||
kind: z.literal("native-feature-trace"),
|
||||
parent_operation: z.literal("trace_feature"),
|
||||
view: nativeFeatureTraceRequests[collection],
|
||||
summary: nativeFeatureTraceSummarySchema,
|
||||
items: nativeFeatureTraceViewItems[collection],
|
||||
});
|
||||
const nativeFeatureTracePageViewResultSchema = z
|
||||
.object({ kind: z.literal("native-feature-trace") })
|
||||
.passthrough()
|
||||
.pipe(
|
||||
z.union([
|
||||
nativeFeatureTracePage("matches"),
|
||||
nativeFeatureTracePage("references"),
|
||||
nativeFeatureTracePage("unknowns"),
|
||||
]),
|
||||
);
|
||||
|
||||
/** Compact provider load observations retain the independent verification state. */
|
||||
export const nativeLoadImageSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
@@ -1192,7 +1153,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
modelArtifactSummaryViewResultSchema,
|
||||
gpuCodeObjectsSummaryViewResultSchema,
|
||||
nativeInventorySummaryViewResultSchema,
|
||||
nativeFeatureTraceSummaryViewResultSchema,
|
||||
nativeLoadImageSummaryViewResultSchema,
|
||||
nativeFunctionMatchingSummaryViewResultSchema,
|
||||
nativeFunctionSummaryViewResultSchema,
|
||||
@@ -1228,7 +1188,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
view: nativeInventoryViewRequestSchema,
|
||||
rows: z.array(nativeInventoryViewRowSchema),
|
||||
}),
|
||||
nativeFeatureTracePageViewResultSchema,
|
||||
z.strictObject({
|
||||
...viewResultBase,
|
||||
kind: z.literal("native-load-image"),
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { literalFeatureMatchSchema } from "../native/literalFeatureMatch.js";
|
||||
import {
|
||||
literalFeatureReferenceSchema,
|
||||
literalFeatureTraceSchema,
|
||||
} from "../native/literalFeatureTrace.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
import { err, ok } from "../result.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import { selectViewRows } from "./selectViewRows.js";
|
||||
import {
|
||||
createAnalysisViewResultReader,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisViewParent.js";
|
||||
import {
|
||||
assertOwnedView,
|
||||
type AdmittedView,
|
||||
type SummaryAnalysisView,
|
||||
} from "./analysisViewShape.js";
|
||||
import {
|
||||
boundedViewMessages,
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
|
||||
const page = {
|
||||
kind: z.literal("native-feature-trace"),
|
||||
offset: z.int().nonnegative().default(0),
|
||||
limit: z.int().positive().default(64),
|
||||
};
|
||||
export const nativeFeatureTraceRequests = {
|
||||
matches: z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("matches"),
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Select rows with this exact retained address."),
|
||||
}),
|
||||
references: z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("references"),
|
||||
source_address: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Select this exact retained source address."),
|
||||
target_address: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Select this exact retained target address."),
|
||||
}),
|
||||
unknowns: z.strictObject({ ...page, collection: z.literal("unknowns") }),
|
||||
} as const;
|
||||
export const nativeFeatureTraceViewRequestSchema = z.discriminatedUnion(
|
||||
"collection",
|
||||
[
|
||||
nativeFeatureTraceRequests.matches,
|
||||
nativeFeatureTraceRequests.references,
|
||||
nativeFeatureTraceRequests.unknowns,
|
||||
],
|
||||
);
|
||||
|
||||
const row = <T extends z.ZodType>(value: T) =>
|
||||
z.strictObject({ source_index: z.int().nonnegative(), value });
|
||||
export const nativeFeatureTraceViewItems = {
|
||||
matches: z.strictObject({
|
||||
collection: z.literal("matches"),
|
||||
rows: z.array(row(literalFeatureMatchSchema)),
|
||||
}),
|
||||
references: z.strictObject({
|
||||
collection: z.literal("references"),
|
||||
rows: z.array(row(literalFeatureReferenceSchema)),
|
||||
}),
|
||||
unknowns: z.strictObject({
|
||||
collection: z.literal("unknowns"),
|
||||
rows: z.array(row(z.string())),
|
||||
}),
|
||||
} as const;
|
||||
export const nativeFeatureTraceSummarySchema = z.strictObject({
|
||||
query: z.string(),
|
||||
search_mode: z.literal("literal"),
|
||||
matches: z.int().nonnegative(),
|
||||
references: z.int().nonnegative(),
|
||||
unknowns: z.int().nonnegative(),
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
|
||||
const readTrace = createAnalysisViewResultReader(literalFeatureTraceSchema);
|
||||
|
||||
/** Page retained trace rows without collapsing independently resolvable unknowns. */
|
||||
export const projectNativeFeatureTraceView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AdmittedView,
|
||||
) => {
|
||||
if (view.kind !== "summary" && view.kind !== "native-feature-trace")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Native feature traces support summary and native-feature-trace views.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
assertOwnedView<
|
||||
SummaryAnalysisView | z.output<typeof nativeFeatureTraceViewRequestSchema>
|
||||
>(view, "summary", "native-feature-trace");
|
||||
const parsed = readTrace(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const trace = parsed.value;
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "trace_feature" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: parent.artifact ?? null,
|
||||
source_provider: parent.provider ?? null,
|
||||
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
|
||||
source_parameters: jsonValueSchema.parse(parent.parameters ?? null),
|
||||
limitations: boundedViewMessages(
|
||||
parent.limitations,
|
||||
parent.evidenceId,
|
||||
"limitations",
|
||||
),
|
||||
unknowns: [
|
||||
"Page coverage describes retained trace rows, not completeness of provider discovery. See the retained unknowns and source Evidence for limitations.",
|
||||
],
|
||||
summary: {
|
||||
query: trace.query,
|
||||
search_mode: trace.search_mode,
|
||||
matches: trace.matches.length,
|
||||
references: trace.references.length,
|
||||
unknowns: trace.residual_unknowns.length,
|
||||
truncated: trace.truncated,
|
||||
},
|
||||
};
|
||||
if (view.kind === "summary")
|
||||
return ok({
|
||||
...shared,
|
||||
kind: "summary" as const,
|
||||
view,
|
||||
coverage: completeWithinViewCoverage(
|
||||
trace.matches.length +
|
||||
trace.references.length +
|
||||
trace.residual_unknowns.length,
|
||||
trace.matches.length +
|
||||
trace.references.length +
|
||||
trace.residual_unknowns.length,
|
||||
),
|
||||
});
|
||||
const select = <Value>(
|
||||
values: readonly Value[],
|
||||
matches?: (value: Value) => boolean,
|
||||
) => {
|
||||
if (matches === undefined) {
|
||||
const rows = values
|
||||
.slice(view.offset, view.offset + view.limit)
|
||||
.map((value, index) => ({ source_index: view.offset + index, value }));
|
||||
return {
|
||||
rows,
|
||||
coverage: pageViewCoverage(view.offset, rows.length, values.length),
|
||||
};
|
||||
}
|
||||
const { rows, total } = selectViewRows(
|
||||
values,
|
||||
view,
|
||||
matches,
|
||||
(value, source_index) => ({ source_index, value }),
|
||||
);
|
||||
return {
|
||||
rows,
|
||||
coverage: pageViewCoverage(view.offset, rows.length, total),
|
||||
};
|
||||
};
|
||||
switch (view.collection) {
|
||||
case "matches": {
|
||||
const { rows, coverage } = select(
|
||||
trace.matches,
|
||||
view.address === undefined
|
||||
? undefined
|
||||
: (row) => row.address === view.address,
|
||||
);
|
||||
return ok({
|
||||
...shared,
|
||||
kind: "native-feature-trace" as const,
|
||||
view: { ...view, collection: "matches" as const },
|
||||
items: { collection: "matches" as const, rows },
|
||||
coverage,
|
||||
});
|
||||
}
|
||||
case "references": {
|
||||
const { rows, coverage } = select(
|
||||
trace.references,
|
||||
view.source_address === undefined && view.target_address === undefined
|
||||
? undefined
|
||||
: (row) =>
|
||||
(view.source_address === undefined ||
|
||||
row.source_address === view.source_address) &&
|
||||
(view.target_address === undefined ||
|
||||
row.target_address === view.target_address),
|
||||
);
|
||||
return ok({
|
||||
...shared,
|
||||
kind: "native-feature-trace" as const,
|
||||
view: { ...view, collection: "references" as const },
|
||||
items: { collection: "references" as const, rows },
|
||||
coverage,
|
||||
});
|
||||
}
|
||||
case "unknowns": {
|
||||
const { rows, coverage } = select(trace.residual_unknowns);
|
||||
return ok({
|
||||
...shared,
|
||||
kind: "native-feature-trace" as const,
|
||||
view: { ...view, collection: "unknowns" as const },
|
||||
items: { collection: "unknowns" as const, rows },
|
||||
coverage,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -127,39 +127,6 @@ const makeUnknown = (
|
||||
};
|
||||
};
|
||||
|
||||
it("retains only linked cross-target source closure in a target snapshot", () => {
|
||||
const leaf = createEvidence(undefined, provider, {
|
||||
operation: "source_leaf",
|
||||
parameters: {},
|
||||
result: "leaf",
|
||||
});
|
||||
const otherTarget = { ...ANALYSIS_SNAPSHOT_TARGET, sha256: foreignDigest };
|
||||
const source = createEvidence(otherTarget, provider, {
|
||||
operation: "source",
|
||||
parameters: {},
|
||||
result: "source",
|
||||
evidenceLinks: [leaf.evidence_id],
|
||||
});
|
||||
const trace = createEvidence(ANALYSIS_SNAPSHOT_TARGET, provider, {
|
||||
operation: "trace",
|
||||
parameters: {},
|
||||
result: "trace",
|
||||
evidenceLinks: [source.evidence_id, "ev_" + "c".repeat(64)],
|
||||
});
|
||||
const unrelated = createEvidence(otherTarget, provider, {
|
||||
operation: "unrelated",
|
||||
parameters: {},
|
||||
result: "unrelated",
|
||||
});
|
||||
const selected = evidenceBundleForTarget(
|
||||
createEvidenceBundle([trace, source, leaf, unrelated]),
|
||||
targetDigest,
|
||||
);
|
||||
expect(selected).toEqual(createEvidenceBundle([trace, source, leaf]));
|
||||
expect(evidenceBundleForTarget(selected, targetDigest)).toEqual(selected);
|
||||
expect(parseEvidenceBundle(selected)).toEqual(selected);
|
||||
});
|
||||
|
||||
describe("evidenceBundleForTarget", () => {
|
||||
it("retains mutation evidence from other targets for complete revision histories", () => {
|
||||
const first = makeUnknown(
|
||||
|
||||
@@ -157,7 +157,7 @@ export const createEvidenceBundle = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Retain target records, their linked sources, and eligible complete unknown histories. */
|
||||
/** Retain target records and eligible complete unknown histories with their mutation evidence. */
|
||||
export const evidenceBundleForTarget = (
|
||||
bundle: EvidenceBundle,
|
||||
sha256: string,
|
||||
@@ -218,30 +218,12 @@ export const evidenceBundleForTarget = (
|
||||
const mutationIds = new Set(
|
||||
unknowns.flatMap(({ mutation_evidence_ids: ids }) => ids),
|
||||
);
|
||||
const selectedIds = new Set(
|
||||
bundle.records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.subject?.digest.sha256 === sha256 ||
|
||||
mutationIds.has(record.evidence_id),
|
||||
)
|
||||
.map((record) => record.evidence_id),
|
||||
);
|
||||
// Source Evidence may have no subject or describe a different artifact.
|
||||
// Follow only explicit links from selected records, retaining missing links
|
||||
// as missing rather than inventing or importing unrelated observations.
|
||||
const pendingEvidence = [...selectedIds];
|
||||
for (let index = 0; index < pendingEvidence.length; index += 1) {
|
||||
const record = recordsById.get(pendingEvidence[index] ?? "");
|
||||
for (const id of record?.evidence_links ?? []) {
|
||||
if (!selectedIds.has(id) && recordsById.has(id)) {
|
||||
selectedIds.add(id);
|
||||
pendingEvidence.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return createEvidenceBundle(
|
||||
bundle.records.filter((record) => selectedIds.has(record.evidence_id)),
|
||||
bundle.records.filter(
|
||||
(record) =>
|
||||
record.subject?.digest.sha256 === sha256 ||
|
||||
mutationIds.has(record.evidence_id),
|
||||
),
|
||||
unknowns,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -289,18 +289,8 @@ describe("Electron protocol registrations without a scheme", () => {
|
||||
|
||||
describe("Electron protocol calls that register nothing", () => {
|
||||
it.each([
|
||||
[
|
||||
"a legacy handler without a callback",
|
||||
'protocol.registerStringProtocol("app");',
|
||||
],
|
||||
[
|
||||
"a legacy handler with a null callback",
|
||||
'protocol.registerStringProtocol("app", null);',
|
||||
],
|
||||
[
|
||||
"a legacy handler with a void callback",
|
||||
'protocol.registerStringProtocol("app", void 0);',
|
||||
],
|
||||
["a handler without a callback", 'protocol.handle("app");'],
|
||||
["a handler with a null callback", 'protocol.handle("app", null);'],
|
||||
[
|
||||
"an interceptor without a callback",
|
||||
'protocol.interceptHttpProtocol("https");',
|
||||
|
||||
@@ -87,12 +87,7 @@ export const inspectElectronProtocolNode = (
|
||||
: INTERCEPTORS.has(target.api)
|
||||
? "interceptor"
|
||||
: undefined;
|
||||
// handle installs its own native callback and invokes the supplied handler
|
||||
// only when a request arrives; a missing handler does not prevent registration.
|
||||
if (
|
||||
role !== undefined &&
|
||||
(target.api === "handle" || !handlerKnownMissing(node))
|
||||
)
|
||||
if (role !== undefined && !handlerKnownMissing(node))
|
||||
addProtocol(context, {
|
||||
node,
|
||||
api: target.api,
|
||||
@@ -102,7 +97,7 @@ export const inspectElectronProtocolNode = (
|
||||
});
|
||||
};
|
||||
|
||||
/** Legacy native registration APIs require a callback before installing it. */
|
||||
/** Electron requires the callback, so a call without one installs nothing. */
|
||||
const handlerKnownMissing = (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
): boolean => {
|
||||
|
||||
@@ -17,10 +17,6 @@ export const ELF_NOTE_TYPE_NAMES = [
|
||||
"gnu_build_id",
|
||||
"gnu_gold_version",
|
||||
"gnu_property",
|
||||
"core_prpsinfo",
|
||||
"core_file",
|
||||
"core_siginfo",
|
||||
"core_auxv",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type ElfNoteTypeName = (typeof ELF_NOTE_TYPE_NAMES)[number];
|
||||
@@ -68,22 +64,6 @@ export const gnuNoteTypeName = (type: number): ElfNoteTypeName => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Map CORE n_type when the owner is CORE; other types stay unknown. */
|
||||
export const coreNoteTypeName = (type: number): ElfNoteTypeName => {
|
||||
switch (type) {
|
||||
case 3:
|
||||
return "core_prpsinfo";
|
||||
case 6:
|
||||
return "core_auxv";
|
||||
case 0x46494c45:
|
||||
return "core_file";
|
||||
case 0x53494749:
|
||||
return "core_siginfo";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
/** Complete bounded inspection of ELF PT_NOTE and SHT_NOTE tables. */
|
||||
export const inspectElfNotesInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
@@ -115,51 +95,6 @@ const gnuAbiTagSchema = z.strictObject({
|
||||
subminor: uint,
|
||||
});
|
||||
|
||||
const coreFileMappingSchema = z.strictObject({
|
||||
start_address: z.string().regex(/^0x[0-9a-f]+$/),
|
||||
end_address: z.string().regex(/^0x[0-9a-f]+$/),
|
||||
file_offset_pages: z.string().regex(/^0x[0-9a-f]+$/),
|
||||
path: z.string(),
|
||||
path_hex: z.string().regex(/^[0-9a-f]*$/),
|
||||
});
|
||||
|
||||
const coreFileSchema = z.strictObject({
|
||||
count: uint,
|
||||
page_size: uint,
|
||||
mappings: z.array(coreFileMappingSchema),
|
||||
});
|
||||
|
||||
const corePrpsinfoSchema = z.strictObject({
|
||||
state: uint,
|
||||
pid: z.number().int(),
|
||||
ppid: z.number().int(),
|
||||
pgrp: z.number().int(),
|
||||
sid: z.number().int(),
|
||||
uid: uint,
|
||||
gid: uint,
|
||||
filename: z.string(),
|
||||
psargs: z.string(),
|
||||
});
|
||||
|
||||
const coreSiginfoSchema = z.strictObject({
|
||||
signo: z.number().int(),
|
||||
code: z.number().int(),
|
||||
errno: z.number().int(),
|
||||
});
|
||||
|
||||
const coreAuxvEntrySchema = z.strictObject({
|
||||
type: uint,
|
||||
type_name: z.string().nullable(),
|
||||
value: z.string().regex(/^0x[0-9a-f]+$/),
|
||||
});
|
||||
|
||||
export const coreNoteSchema = z.strictObject({
|
||||
prpsinfo: corePrpsinfoSchema.nullable(),
|
||||
file: coreFileSchema.nullable(),
|
||||
siginfo: coreSiginfoSchema.nullable(),
|
||||
auxv: z.array(coreAuxvEntrySchema).nullable(),
|
||||
});
|
||||
|
||||
const noteEntrySchema = z.strictObject({
|
||||
index: uint,
|
||||
table_kind: z.enum(["program", "section"]),
|
||||
@@ -190,7 +125,6 @@ const noteEntrySchema = z.strictObject({
|
||||
gold_version: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
core: coreNoteSchema.nullable(),
|
||||
});
|
||||
|
||||
/** Observed ELF note tables; absence is an empty list, not proof notes never existed. */
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { literalFeatureMatchSchema } from "./literalFeatureMatch.js";
|
||||
import { traceContainingProcedureSchema } from "./nativeObservation.js";
|
||||
|
||||
/** One observed reference with an independently resolved or unavailable owner. */
|
||||
export const literalFeatureReferenceSchema = z.object({
|
||||
target_address: z.string(),
|
||||
source_address: z.string(),
|
||||
containing_procedure: traceContainingProcedureSchema,
|
||||
});
|
||||
|
||||
/** Complete result of a native literal feature trace. */
|
||||
export const literalFeatureTraceSchema = z.object({
|
||||
query: z.string(),
|
||||
search_mode: z.literal("literal"),
|
||||
matches: z.array(literalFeatureMatchSchema),
|
||||
references: z.array(literalFeatureReferenceSchema),
|
||||
truncated: z.boolean(),
|
||||
residual_unknowns: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type LiteralFeatureReference = z.infer<
|
||||
typeof literalFeatureReferenceSchema
|
||||
>;
|
||||
@@ -152,8 +152,8 @@ it("preserves OpenStep dictionary and array autodetection", () => {
|
||||
});
|
||||
|
||||
it("preserves OpenStep __proto__ keys and duplicate assignment semantics", () => {
|
||||
// Unquoted, quoted, escape-spelled, and octal-spelled keys all decode to
|
||||
// __proto__; last assignment wins.
|
||||
// Unquoted, quoted, escape-spelled, and object-valued keys all decode to
|
||||
// __proto__; values, other keys, and `\137` (decoded as "137") do not.
|
||||
const result = parseXmlPropertyList(
|
||||
'{ __proto__ = a; "__proto__" = b; "\\_\\_proto__" = c; ' +
|
||||
'nested = { "__proto__" = { polluted = yes; }; }; ' +
|
||||
@@ -161,10 +161,11 @@ it("preserves OpenStep __proto__ keys and duplicate assignment semantics", () =>
|
||||
);
|
||||
expect(result).toEqual({
|
||||
value: {
|
||||
["__proto__"]: "d",
|
||||
["__proto__"]: "c",
|
||||
nested: { ["__proto__"]: { polluted: "yes" } },
|
||||
kept: "__proto__",
|
||||
list: ["__proto__"],
|
||||
"137_proto__": "d",
|
||||
},
|
||||
});
|
||||
const nested = (result.value as { nested: object }).nested;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
it.each([
|
||||
"{ numeric = (42, -2.5, 0x10, YES); path = /a/b:c$; }",
|
||||
String.raw`{ escapes = "\n\r\t\0\"\\\q\137\U0041"; }`,
|
||||
"{ bytes = (<ffzz0>, <0>, <a b c d>, <>); }",
|
||||
'/* header */ ("//text", "/*text*/", /* before */ value, // after\n)',
|
||||
"{ first = x second = y; third = (a, b,); }",
|
||||
@@ -168,27 +169,3 @@ it("does not treat nonterminating whitespace or quoted comment text as line ends
|
||||
),
|
||||
).toMatchObject({ code: "unexpected_end" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['{ CFBundleExecutable = "M\\U0061in"; }', { CFBundleExecutable: "Main" }],
|
||||
['{ CFBundleExecutable = "M\\141in"; }', { CFBundleExecutable: "Main" }],
|
||||
['{ "CFBundle\\U0045xecutable" = "Main"; }', { CFBundleExecutable: "Main" }],
|
||||
['{ accent = "\\U00e9"; }', { accent: "é" }],
|
||||
['{ nextstep = "\\335"; }', { nextstep: "é" }],
|
||||
[
|
||||
String.raw`{ escapes = "\n\r\t\0\"\\\q\137\U0041"; }`,
|
||||
{ escapes: '\n\r\t\0"\\q_A' },
|
||||
],
|
||||
['{ controls = "\\a\\b\\f\\v"; }', { controls: "\u0007\b\f\v" }],
|
||||
['{ surrogatePair = "\\Ud83d\\Ude00"; }', { surrogatePair: "😀" }],
|
||||
['{ loneHighSurrogate = "\\Ud800"; }', { loneHighSurrogate: "\ufffd" }],
|
||||
['{ loneLowSurrogate = "\\Udc00"; }', { loneLowSurrogate: "\ufffd" }],
|
||||
['{ octalBoundary376 = "\\376"; }', { octalBoundary376: "\ufffd" }],
|
||||
['{ octalBoundary377 = "\\377"; }', { octalBoundary377: "\ufffd" }],
|
||||
['{ octalWrap400 = "\\400"; }', { octalWrap400: "\0" }],
|
||||
])(
|
||||
"decodes OpenStep quoted escape sequences in keys and values for %s",
|
||||
(text, expected) => {
|
||||
expect(decodeOpenStepPropertyList(text)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
@@ -13,159 +13,6 @@ const OPEN_STEP_SYNTAX_CODES = [
|
||||
"unexpected_character",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* NeXTSTEP character encoding mapping table for octal bytes 0x80 (128) to 0xFF (255)
|
||||
* defined by Apple CoreFoundation CFOldStylePList / CFBuiltinConverters.
|
||||
*/
|
||||
const NEXT_STEP_LATIN_TABLE: readonly number[] = [
|
||||
0x00a0, 0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c7, 0x00c8,
|
||||
0x00c9, 0x00ca, 0x00cb, 0x00cc, 0x00cd, 0x00ce, 0x00cf, 0x00d0, 0x00d1,
|
||||
0x00d2, 0x00d3, 0x00d4, 0x00d5, 0x00d6, 0x00d9, 0x00da, 0x00db, 0x00dc,
|
||||
0x00dd, 0x00de, 0x00b5, 0x00d7, 0x00f7, 0x00a9, 0x00a1, 0x00a2, 0x00a3,
|
||||
0x2044, 0x00a5, 0x0192, 0x00a7, 0x00a4, 0x2019, 0x201c, 0x00ab, 0x2039,
|
||||
0x203a, 0xfb01, 0xfb02, 0x00ae, 0x2013, 0x2020, 0x2021, 0x00b7, 0x00a6,
|
||||
0x00b6, 0x2022, 0x201a, 0x201e, 0x201d, 0x00bb, 0x2026, 0x2030, 0x00ac,
|
||||
0x00bf, 0x00b9, 0x02cb, 0x00b4, 0x02c6, 0x02dc, 0x00af, 0x02d8, 0x02d9,
|
||||
0x00a8, 0x00b2, 0x02da, 0x00b8, 0x00b3, 0x02dd, 0x02db, 0x02c7, 0x2014,
|
||||
0x00b1, 0x00bc, 0x00bd, 0x00be, 0x00e0, 0x00e1, 0x00e2, 0x00e3, 0x00e4,
|
||||
0x00e5, 0x00e7, 0x00e8, 0x00e9, 0x00ea, 0x00eb, 0x00ec, 0x00c6, 0x00ed,
|
||||
0x00aa, 0x00ee, 0x00ef, 0x00f0, 0x00f1, 0x0141, 0x00d8, 0x0152, 0x00ba,
|
||||
0x00f2, 0x00f3, 0x00f4, 0x00f5, 0x00f6, 0x00e6, 0x00f9, 0x00fa, 0x00fb,
|
||||
0x0131, 0x00fc, 0x00fd, 0x0142, 0x00f8, 0x0153, 0x00df, 0x00fe, 0x00ff,
|
||||
0xfffd, 0xfffd,
|
||||
];
|
||||
|
||||
/**
|
||||
* Decode OpenStep quoted string escape sequences according to Apple CoreFoundation CFOldStylePList:
|
||||
* supports \a, \b, \f, \n, \r, \t, \v, \", \\, \Uxxxx (4-hex unicode), and \ooo (1-3 octal digits in NeXTSTEP encoding).
|
||||
*/
|
||||
export const decodeOpenStepQuotedString = (token: string): string => {
|
||||
// Validate token syntax with pinned parser to preserve parser error diagnosis.
|
||||
parseOpenStep(token);
|
||||
const content = token.slice(1, -1);
|
||||
let result = "";
|
||||
let i = 0;
|
||||
while (i < content.length) {
|
||||
const ch = content[i]!;
|
||||
if (ch !== "\\") {
|
||||
result += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
if (i >= content.length) break;
|
||||
const esc = content[i]!;
|
||||
i += 1;
|
||||
switch (esc) {
|
||||
case "a":
|
||||
result += "\u0007";
|
||||
break;
|
||||
case "b":
|
||||
result += "\b";
|
||||
break;
|
||||
case "f":
|
||||
result += "\f";
|
||||
break;
|
||||
case "n":
|
||||
result += "\n";
|
||||
break;
|
||||
case "r":
|
||||
result += "\r";
|
||||
break;
|
||||
case "t":
|
||||
result += "\t";
|
||||
break;
|
||||
case "v":
|
||||
result += "\v";
|
||||
break;
|
||||
case '"':
|
||||
result += '"';
|
||||
break;
|
||||
case "\\":
|
||||
result += "\\";
|
||||
break;
|
||||
case "\n":
|
||||
result += "\n";
|
||||
break;
|
||||
case "U": {
|
||||
let num = 0;
|
||||
let digits = 0;
|
||||
while (digits < 4 && i < content.length) {
|
||||
const hexChar = content[i]!;
|
||||
if (/^[0-9a-fA-F]$/.test(hexChar)) {
|
||||
num = (num << 4) | parseInt(hexChar, 16);
|
||||
i += 1;
|
||||
digits += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (num >= 0xd800 && num <= 0xdbff) {
|
||||
if (content[i] === "\\" && content[i + 1] === "U") {
|
||||
let lowNum = 0;
|
||||
let lowDigits = 0;
|
||||
let j = i + 2;
|
||||
while (lowDigits < 4 && j < content.length) {
|
||||
const hexChar = content[j]!;
|
||||
if (/^[0-9a-fA-F]$/.test(hexChar)) {
|
||||
lowNum = (lowNum << 4) | parseInt(hexChar, 16);
|
||||
j += 1;
|
||||
lowDigits += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lowDigits === 4 && lowNum >= 0xdc00 && lowNum <= 0xdfff) {
|
||||
i = j;
|
||||
result += String.fromCharCode(num, lowNum);
|
||||
break;
|
||||
}
|
||||
}
|
||||
result += "\ufffd";
|
||||
} else if (num >= 0xdc00 && num <= 0xdfff) {
|
||||
result += "\ufffd";
|
||||
} else {
|
||||
result += String.fromCharCode(num);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "0":
|
||||
case "1":
|
||||
case "2":
|
||||
case "3":
|
||||
case "4":
|
||||
case "5":
|
||||
case "6":
|
||||
case "7": {
|
||||
let num = esc.charCodeAt(0) - 48;
|
||||
for (let j = 0; j < 2; j += 1) {
|
||||
if (i < content.length) {
|
||||
const octChar = content[i]!;
|
||||
if (octChar >= "0" && octChar <= "7") {
|
||||
num = ((num << 3) + (octChar.charCodeAt(0) - 48)) & 0xff;
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (num < 0x80) {
|
||||
result += String.fromCharCode(num);
|
||||
} else {
|
||||
const codePoint = NEXT_STEP_LATIN_TABLE[num - 0x80];
|
||||
result +=
|
||||
codePoint !== undefined ? String.fromCharCode(codePoint) : "\ufffd";
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
result += esc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type OpenStepPropertyListCode = (typeof OPEN_STEP_SYNTAX_CODES)[number];
|
||||
|
||||
/** OpenStep syntax refusal. `code` is the contract; `position` is the source offset. */
|
||||
@@ -316,10 +163,9 @@ export const decodeOpenStepPropertyList = (
|
||||
// Scalar dictionary keys do not add source-value nesting; compound keys do.
|
||||
if (!dictionaryKey) assertDepth(depth);
|
||||
const token = text.slice(start, cursor);
|
||||
if (character === '"') {
|
||||
return decodeOpenStepQuotedString(token);
|
||||
}
|
||||
return character === "<" ? parseOpenStep(token) : token;
|
||||
return character === '"' || character === "<"
|
||||
? parseOpenStep(token)
|
||||
: token;
|
||||
};
|
||||
// The pinned format reads one root and leaves all trailing source untouched.
|
||||
return value(0);
|
||||
|
||||
@@ -53,7 +53,7 @@ const objdumpDisassemblyInputSchema = objdumpArtifactInputSchema.extend({
|
||||
"Symbol must be well-formed Unicode.",
|
||||
)
|
||||
.describe(
|
||||
"Native symbol to disassemble, expressed as well-formed Unicode. Matching and symbol boundaries follow the selected executable; omitted means all code sections.",
|
||||
"Native symbol to disassemble, expressed as well-formed Unicode. Matching and symbol boundaries follow GNU objdump; omitted means all code sections.",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ const sharedAnalysisSemantics = {
|
||||
annotation_policy: "atomic-function-entry-metadata-v1",
|
||||
load_image_observations: "source-mappings-entry-context-relocation-symbol-v3",
|
||||
function_body_evidence: "complete-inclusive-ranges-v1",
|
||||
function_references: "complete-body-and-entry-reference-manager-v3",
|
||||
function_references: "complete-body-and-entry-reference-manager-v2",
|
||||
call_relationship_coverage: "function-body-call-coverage-v5",
|
||||
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
|
||||
string_inventory_evidence: "defined-data-coverage-v1",
|
||||
@@ -67,7 +67,7 @@ const sharedAnalysisSemantics = {
|
||||
"address-first-unique-target-source-mapping-v2",
|
||||
instruction_flow_evidence: "decoded-return-pcode-v1",
|
||||
instruction_status: "unlisted-address-without-decode-v1",
|
||||
instruction_references: "exact-address-incoming-without-synthetic-entry-v3",
|
||||
instruction_references: "exact-address-incoming-without-synthetic-entry-v2",
|
||||
function_boundary_observations: "ghidra-function-boundary-limitations-v4",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -3,12 +3,6 @@ import {
|
||||
EXISTING_PROJECT_WINDOWS_REMEDIATION,
|
||||
EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
|
||||
existingProjectCapabilities,
|
||||
ghidraEmptyIncomingReferencesLimitation,
|
||||
ghidraEmptyDossierIncomingLimitation,
|
||||
ghidraEmptyExternalOutgoingLimitation,
|
||||
ghidraEmptyInstructionOutgoingLimitation,
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
ghidraEmptyXrefsLimitation,
|
||||
} from "./GhidraProviderCapabilities.js";
|
||||
import { AnalysisProviderRegistry } from "../application/binary/AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "../application/binary/BinarySessionComposition.js";
|
||||
@@ -50,16 +44,6 @@ import {
|
||||
nativeAotPeVirtualSectionFixture,
|
||||
} from "../../tests/fixtures/nativeaotPe.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { functionDossierSchema } from "../domain/native/functionDossier.js";
|
||||
import {
|
||||
compactInstruction,
|
||||
compactReference,
|
||||
} from "../domain/native/nativeInstruction.fixture.js";
|
||||
import {
|
||||
ghidraFunctionDossier,
|
||||
ghidraFunctionIdentity,
|
||||
ghidraReferenceEdge,
|
||||
} from "../domain/ghidraValues.fixture.js";
|
||||
|
||||
import { parseConfig } from "../config/parseConfig.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
@@ -3138,439 +3122,6 @@ const sessionInfo = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
describe("Ghidra empty reference diagnostics", () => {
|
||||
const emptyReferenceProvider = (result: JsonValue) =>
|
||||
provider(installationHost(), () => ({
|
||||
start: () => Promise.resolve(ok(sessionInfo())),
|
||||
callTool: () => Promise.resolve(ok(result)),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
|
||||
const observation = (value: JsonValue) => ({ value, limitations: [] });
|
||||
|
||||
it("names unresolved references in the limitations of an empty xrefs result", async () => {
|
||||
const ghidra = emptyReferenceProvider([]);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("xrefs", {
|
||||
address: "0x1005409a2",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.result).toEqual([]);
|
||||
expect(execution.value.limitations).toContain(ghidraEmptyXrefsLimitation);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not add the unresolved-references caveat to a non-empty xrefs result", async () => {
|
||||
const ghidra = emptyReferenceProvider(["0x100003670"]);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("xrefs", {
|
||||
address: "0x1005409a2",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.result).toEqual(["0x100003670"]);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyXrefsLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["incoming", "outgoing"] as const)(
|
||||
"qualifies the empty %s direction in procedure_references limitations",
|
||||
async (direction) => {
|
||||
const ghidra = emptyReferenceProvider(
|
||||
observation({
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
direction,
|
||||
references: [],
|
||||
reference_kinds_available: true,
|
||||
unresolved_calls: [],
|
||||
}),
|
||||
);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("procedure_references", {
|
||||
procedure: "0x401000",
|
||||
direction,
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.result).toMatchObject({ references: [] });
|
||||
const expected =
|
||||
direction === "outgoing"
|
||||
? ghidraEmptyOutgoingReferencesLimitation
|
||||
: ghidraEmptyIncomingReferencesLimitation;
|
||||
const otherDirection =
|
||||
direction === "outgoing"
|
||||
? ghidraEmptyIncomingReferencesLimitation
|
||||
: ghidraEmptyOutgoingReferencesLimitation;
|
||||
expect(execution.value.limitations).toContain(expected);
|
||||
expect(execution.value.limitations).not.toContain(otherDirection);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not add the caveat to procedure_references that returned references", async () => {
|
||||
const ghidra = emptyReferenceProvider(
|
||||
observation({
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
direction: "outgoing",
|
||||
references: [ghidraReferenceEdge()],
|
||||
reference_kinds_available: true,
|
||||
unresolved_calls: [],
|
||||
}),
|
||||
);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("procedure_references", {
|
||||
procedure: "0x401000",
|
||||
direction: "outgoing",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyIncomingReferencesLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("names the external-specific outgoing caveat for an external procedure_references result", async () => {
|
||||
const identity = ghidraFunctionIdentity();
|
||||
const ghidra = emptyReferenceProvider(
|
||||
observation({
|
||||
procedure: {
|
||||
...identity,
|
||||
classification: { ...identity.classification, external: true },
|
||||
},
|
||||
direction: "outgoing",
|
||||
references: [],
|
||||
reference_kinds_available: true,
|
||||
unresolved_calls: [],
|
||||
}),
|
||||
);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("procedure_references", {
|
||||
procedure: "0x401000",
|
||||
direction: "outgoing",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyExternalOutgoingLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra empty function-reference facets", () => {
|
||||
const emptyReferenceProvider = (result: JsonValue) =>
|
||||
provider(installationHost(), () => ({
|
||||
start: () => Promise.resolve(ok(sessionInfo())),
|
||||
callTool: () => Promise.resolve(ok(result)),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
|
||||
const observation = (value: JsonValue) => ({ value, limitations: [] });
|
||||
|
||||
it("names each unresolved direction in the limitations of a dossier without any references", async () => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const ghidra = emptyReferenceProvider(
|
||||
observation({
|
||||
...dossier,
|
||||
incoming_references: [],
|
||||
outgoing_references: [],
|
||||
}),
|
||||
);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("analyze_function", {
|
||||
procedure: "0x401000",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyDossierIncomingLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyIncomingReferencesLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("qualifies the empty incoming direction when a dossier observed only outgoing references", async () => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const ghidra = emptyReferenceProvider(
|
||||
observation({ ...dossier, incoming_references: [] }),
|
||||
);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("analyze_function", {
|
||||
procedure: "0x401000",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyDossierIncomingLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyIncomingReferencesLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("qualifies the empty outgoing direction when a dossier observed only incoming references", async () => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const ghidra = emptyReferenceProvider(
|
||||
observation({
|
||||
...dossier,
|
||||
incoming_references: [ghidraReferenceEdge()],
|
||||
outgoing_references: [],
|
||||
}),
|
||||
);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("analyze_function", {
|
||||
procedure: "0x401000",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyDossierIncomingLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("qualifies an external function's empty facets without local-disassembly guidance", async () => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const ghidra = emptyReferenceProvider(
|
||||
observation({
|
||||
...dossier,
|
||||
procedure: {
|
||||
...dossier.procedure,
|
||||
classification: {
|
||||
...dossier.procedure.classification,
|
||||
external: true,
|
||||
},
|
||||
},
|
||||
incoming_references: [],
|
||||
outgoing_references: [],
|
||||
}),
|
||||
);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("analyze_function", {
|
||||
procedure: "0x401000",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyIncomingReferencesLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyExternalOutgoingLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyDossierIncomingLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra empty annotation reference facets", () => {
|
||||
const emptyReferenceProvider = (result: JsonValue) =>
|
||||
provider(installationHost(), () => ({
|
||||
start: () => Promise.resolve(ok(sessionInfo())),
|
||||
callTool: () => Promise.resolve(ok(result)),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
|
||||
it("names each unresolved direction when annotation readback observes no references", async () => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const ghidra = emptyReferenceProvider({
|
||||
annotations: {
|
||||
address: "0x401000",
|
||||
name: "fixture_main",
|
||||
comment: null,
|
||||
inline_comment: null,
|
||||
},
|
||||
dossier: { ...dossier, incoming_references: [], outgoing_references: [] },
|
||||
effects: {
|
||||
scope: "session-analysis-database",
|
||||
source_bytes_modified: false,
|
||||
persists_after_close: false,
|
||||
},
|
||||
});
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("annotate_native_function", {
|
||||
procedure: "0x401000",
|
||||
name: "fixture_main",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyDossierIncomingLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("qualifies the empty direction when annotation readback observed one direction only", async () => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const ghidra = emptyReferenceProvider({
|
||||
annotations: {
|
||||
address: "0x401000",
|
||||
name: "fixture_main",
|
||||
comment: null,
|
||||
inline_comment: null,
|
||||
},
|
||||
dossier: {
|
||||
...dossier,
|
||||
incoming_references: [ghidraReferenceEdge()],
|
||||
outgoing_references: [],
|
||||
},
|
||||
effects: {
|
||||
scope: "session-analysis-database",
|
||||
source_bytes_modified: false,
|
||||
persists_after_close: false,
|
||||
},
|
||||
});
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("annotate_native_function", {
|
||||
procedure: "0x401000",
|
||||
name: "fixture_main",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.limitations).toContain(
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
);
|
||||
expect(execution.value.limitations).not.toContain(
|
||||
ghidraEmptyIncomingReferencesLimitation,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra empty instruction reference facets", () => {
|
||||
const emptyReferenceProvider = (result: JsonValue) =>
|
||||
provider(installationHost(), () => ({
|
||||
start: () => Promise.resolve(ok(sessionInfo())),
|
||||
callTool: () => Promise.resolve(ok(result)),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
|
||||
const instruction = (incoming: JsonValue[], outgoing: JsonValue[]) => ({
|
||||
...compactInstruction(outgoing),
|
||||
incoming_references: incoming,
|
||||
});
|
||||
const nonDecoded = (status: JsonValue, incoming: JsonValue[]) => ({
|
||||
...compactInstruction(),
|
||||
status,
|
||||
bytes: null,
|
||||
length: null,
|
||||
mnemonic: null,
|
||||
raw_disassembly: null,
|
||||
incoming_references: incoming,
|
||||
});
|
||||
const executeInspect = async (
|
||||
result: JsonValue,
|
||||
): Promise<readonly string[]> => {
|
||||
const ghidra = emptyReferenceProvider(result);
|
||||
const client = await createElfClient(ghidra);
|
||||
try {
|
||||
const execution = await client.execute("inspect_native_instruction", {
|
||||
document: null,
|
||||
address: "0x401000",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
return execution.value.limitations;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
};
|
||||
|
||||
it("names each unresolved facet when neither incoming nor outgoing references were observed", async () => {
|
||||
const limitations = await executeInspect(instruction([], []));
|
||||
expect(limitations).toContain(ghidraEmptyXrefsLimitation);
|
||||
expect(limitations).toContain(ghidraEmptyInstructionOutgoingLimitation);
|
||||
});
|
||||
|
||||
it("qualifies the empty incoming list when only outgoing references were observed", async () => {
|
||||
const limitations = await executeInspect(
|
||||
instruction([], [compactReference()]),
|
||||
);
|
||||
expect(limitations).toContain(ghidraEmptyXrefsLimitation);
|
||||
expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation);
|
||||
});
|
||||
|
||||
it("qualifies the empty outgoing references when only an incoming list was observed", async () => {
|
||||
const limitations = await executeInspect(
|
||||
instruction([compactReference()], []),
|
||||
);
|
||||
expect(limitations).toContain(ghidraEmptyInstructionOutgoingLimitation);
|
||||
expect(limitations).not.toContain(ghidraEmptyXrefsLimitation);
|
||||
});
|
||||
|
||||
it("adds no caveat when both instruction reference facets observed references", async () => {
|
||||
const limitations = await executeInspect(
|
||||
instruction([compactReference()], [compactReference()]),
|
||||
);
|
||||
expect(limitations).not.toContain(ghidraEmptyXrefsLimitation);
|
||||
expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation);
|
||||
});
|
||||
|
||||
it("keeps only the address caveat when a data address resolves no incoming references", async () => {
|
||||
const limitations = await executeInspect(nonDecoded("data", []));
|
||||
expect(limitations).toContain(ghidraEmptyXrefsLimitation);
|
||||
expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation);
|
||||
});
|
||||
|
||||
it("adds no caveat for a data address whose incoming references were observed", async () => {
|
||||
const limitations = await executeInspect(
|
||||
nonDecoded("data", [compactReference()]),
|
||||
);
|
||||
expect(limitations).not.toContain(ghidraEmptyXrefsLimitation);
|
||||
expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra PDB coverage", () => {
|
||||
it.each([
|
||||
{ name: "missing bridge report", pdb: undefined, warns: true },
|
||||
|
||||
@@ -51,34 +51,6 @@ const stringInventoryLimitation =
|
||||
const searchLimitation =
|
||||
"Regex mode uses Java Pattern semantics. Matcher or compiler stack exhaustion returns a resource constraint without closing the session; literal mode avoids regex recursion and returns complete matching values.";
|
||||
|
||||
/** Attached to an empty xrefs result: absence of resolved references is not proof. */
|
||||
export const ghidraEmptyXrefsLimitation =
|
||||
"Ghidra's ReferenceManager returned no actionable references to this address in this session. An empty list reflects what this analysis resolved, not proof that the address is unreferenced; synthetic entry-point references are omitted. No independent in-product cross-check exists for this empty result: surrounding disassembly can confirm direct references at a code address, and data or string addresses need an external disassembly scan (for example otool -tV, pairing adrp with add or ldr) before concluding there are no xrefs.";
|
||||
|
||||
/** Attached to an empty incoming-reference facet of a function result. */
|
||||
export const ghidraEmptyIncomingReferencesLimitation =
|
||||
"Ghidra's ReferenceManager returned no actionable incoming references for this function in this session. An empty incoming list reflects what this analysis resolved, not proof that nothing references this function; synthetic entry-point references are omitted. Incoming references have no independent in-product check and require scanning other code (for example an external otool -tV adrp/add pairing) before concluding there are none.";
|
||||
|
||||
/**
|
||||
* Attached to an empty incoming-reference facet of an analyzed-function
|
||||
* dossier, which omits synthetic entry-point references and excludes sources
|
||||
* inside the function's own body.
|
||||
*/
|
||||
export const ghidraEmptyDossierIncomingLimitation =
|
||||
"Ghidra's ReferenceManager returned no actionable incoming references from outside this function's body in this session. An empty incoming list reflects what this analysis resolved, not proof that nothing references this function: synthetic entry-point references are omitted before the dossier's body filter, and sources inside the body are excluded (a self-recursive call is one), so the list can be empty even when such edges exist. In-body edges remain visible in the outgoing references and disassembly, and procedure_references(incoming) reads the same ReferenceManager stream without the in-body exclusion (it omits synthetic entry-point references as well) to separate the cases. Sources outside the program's observed references still require an external scan (for example otool -tV, pairing adrp with add or ldr) before concluding there are none.";
|
||||
|
||||
/** Attached to an empty outgoing-reference facet of an external function. */
|
||||
export const ghidraEmptyExternalOutgoingLimitation =
|
||||
"Ghidra's ReferenceManager returned no actionable outgoing references for this external function in this session. An external function's defining body is not part of this program database, so its outgoing list cannot describe what the function references: this program's call sites (its incoming references and callers) and the defining artifact are the checks before concluding anything about its behavior.";
|
||||
|
||||
/** Attached to an empty outgoing-reference facet of a function result. */
|
||||
export const ghidraEmptyOutgoingReferencesLimitation =
|
||||
"Ghidra's ReferenceManager returned no actionable outgoing references for this function in this session. An empty outgoing list reflects what this analysis resolved, not proof that the function references nothing; synthetic entry-point references are omitted. The function's own disassembly is an independent check for direct outgoing references before concluding there are none.";
|
||||
|
||||
/** Attached to an empty outgoing-reference list of one instruction result. */
|
||||
export const ghidraEmptyInstructionOutgoingLimitation =
|
||||
"Ghidra's ReferenceManager returned no actionable outgoing references from this instruction in this session. An empty list reflects what this analysis resolved, not proof that the instruction references nothing. The instruction's own disassembly is an independent check for direct outgoing references (a direct operand naming a target with no recorded reference contradicts the empty result) before concluding there are none.";
|
||||
|
||||
/** Limitation text for one admitted operation, including the common base. */
|
||||
export const limitationsFor = (
|
||||
operation: string,
|
||||
|
||||
@@ -74,7 +74,6 @@ import {
|
||||
isGhidraFunctionOperation,
|
||||
parseGhidraFunctionInput,
|
||||
parseGhidraFunctionResult,
|
||||
type GhidraFunctionOperation,
|
||||
} from "./GhidraFunctionValues.js";
|
||||
import {
|
||||
isGhidraInventoryOperation,
|
||||
@@ -104,12 +103,6 @@ import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js";
|
||||
import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js";
|
||||
import {
|
||||
ghidraHealthLimitations,
|
||||
ghidraEmptyXrefsLimitation,
|
||||
ghidraEmptyIncomingReferencesLimitation,
|
||||
ghidraEmptyDossierIncomingLimitation,
|
||||
ghidraEmptyExternalOutgoingLimitation,
|
||||
ghidraEmptyInstructionOutgoingLimitation,
|
||||
ghidraEmptyOutgoingReferencesLimitation,
|
||||
windowsP0Limitations,
|
||||
limitationsFor,
|
||||
} from "./GhidraProviderCapabilities.js";
|
||||
@@ -162,111 +155,6 @@ interface GhidraExecutionMetadata {
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
}
|
||||
|
||||
/** The ReferenceManager observation record of one function-reference result. */
|
||||
const referenceObservationRecord = (
|
||||
normalized: JsonValue,
|
||||
): Readonly<Record<string, JsonValue>> | undefined =>
|
||||
typeof normalized === "object" &&
|
||||
normalized !== null &&
|
||||
!Array.isArray(normalized)
|
||||
? (normalized as Readonly<Record<string, JsonValue>>)
|
||||
: undefined;
|
||||
|
||||
const isEmptyArray = (value: JsonValue | undefined | null): boolean =>
|
||||
Array.isArray(value) && value.length === 0;
|
||||
|
||||
/**
|
||||
* Caveats for the empty reference directions observed in one record: each
|
||||
* empty facet is qualified independently, because the provider's empty answer
|
||||
* is unresolved, not proof of absence for that direction. External functions
|
||||
* (no local body) and the in-body-filtered dossier incoming list get wording
|
||||
* that matches what was actually observed.
|
||||
*/
|
||||
const isExternalProcedureRecord = (
|
||||
record: Readonly<Record<string, JsonValue>>,
|
||||
): boolean => {
|
||||
const procedure = referenceObservationRecord(record.procedure ?? null);
|
||||
const classification =
|
||||
procedure === undefined
|
||||
? undefined
|
||||
: referenceObservationRecord(procedure.classification ?? null);
|
||||
return classification?.external === true;
|
||||
};
|
||||
|
||||
const incomingCaveat = (external: boolean): string =>
|
||||
external
|
||||
? ghidraEmptyIncomingReferencesLimitation
|
||||
: ghidraEmptyDossierIncomingLimitation;
|
||||
|
||||
const outgoingCaveat = (external: boolean): string =>
|
||||
external
|
||||
? ghidraEmptyExternalOutgoingLimitation
|
||||
: ghidraEmptyOutgoingReferencesLimitation;
|
||||
|
||||
const emptyReferenceDirectionCaveats = (
|
||||
record: Readonly<Record<string, JsonValue>>,
|
||||
): readonly string[] => {
|
||||
const external = isExternalProcedureRecord(record);
|
||||
return [
|
||||
...(isEmptyArray(record.incoming_references)
|
||||
? [incomingCaveat(external)]
|
||||
: []),
|
||||
...(isEmptyArray(record.outgoing_references)
|
||||
? [outgoingCaveat(external)]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
|
||||
/** Caveat for a procedure_references result that resolved no references. */
|
||||
const emptyProcedureReferencesCaveat = (
|
||||
record: Readonly<Record<string, JsonValue>>,
|
||||
): string | undefined => {
|
||||
if (!isEmptyArray(record.references)) return undefined;
|
||||
return record.direction === "outgoing"
|
||||
? outgoingCaveat(isExternalProcedureRecord(record))
|
||||
: ghidraEmptyIncomingReferencesLimitation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Caveats for an empty ReferenceManager observation, in limitation order:
|
||||
* the provider's empty answer is unresolved, not proof of absence. xrefs
|
||||
* reports per address; the function-reference facets per empty direction.
|
||||
*/
|
||||
const emptyReferenceCaveat = (
|
||||
operation: GhidraFunctionOperation,
|
||||
normalized: JsonValue,
|
||||
): readonly string[] => {
|
||||
if (operation === "xrefs")
|
||||
return isEmptyArray(normalized) ? [ghidraEmptyXrefsLimitation] : [];
|
||||
const record = referenceObservationRecord(normalized);
|
||||
if (record === undefined) return [];
|
||||
if (operation === "procedure_references") {
|
||||
const caveat = emptyProcedureReferencesCaveat(record);
|
||||
return caveat === undefined ? [] : [caveat];
|
||||
}
|
||||
if (operation === "analyze_function")
|
||||
return emptyReferenceDirectionCaveats(record);
|
||||
if (operation === "annotate_native_function") {
|
||||
const dossier = referenceObservationRecord(record.dossier ?? null);
|
||||
return dossier === undefined ? [] : emptyReferenceDirectionCaveats(dossier);
|
||||
}
|
||||
if (operation === "inspect_native_instruction") {
|
||||
// The incoming list and the outgoing references are independent
|
||||
// ReferenceManager facets; each empty facet is qualified on its own.
|
||||
// The outgoing list is only collected for a decoded instruction — other
|
||||
// statuses return before the ReferenceManager is queried at all.
|
||||
return [
|
||||
...(isEmptyArray(record.incoming_references)
|
||||
? [ghidraEmptyXrefsLimitation]
|
||||
: []),
|
||||
...(record.status === "decoded" && isEmptyArray(record.references)
|
||||
? [ghidraEmptyInstructionOutgoingLimitation]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/** Build one AnalysisClient for an admitted Ghidra target and profile. */
|
||||
export const createGhidraProviderClient = (input: {
|
||||
readonly config: AppConfig;
|
||||
@@ -958,21 +846,6 @@ export const createGhidraProviderClient = (input: {
|
||||
: parseGhidraInventoryResult(operation, called.value);
|
||||
if (!result.ok) return result;
|
||||
let { value: normalized, ...observation } = result.value;
|
||||
let executionMetadata: GhidraExecutionMetadata = observation;
|
||||
const unresolvedReferences = isGhidraFunctionOperation(operation)
|
||||
? emptyReferenceCaveat(operation, normalized)
|
||||
: [];
|
||||
if (unresolvedReferences.length > 0) {
|
||||
// An empty list is the provider's observation, not proof: distinguish
|
||||
// "no references were resolved" from "there are no references".
|
||||
executionMetadata = {
|
||||
...executionMetadata,
|
||||
limitations: [
|
||||
...(executionMetadata.limitations ?? []),
|
||||
...unresolvedReferences,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (
|
||||
(operation === "search_procedures" || operation === "search_strings") &&
|
||||
input.value.mode === "literal" &&
|
||||
@@ -983,7 +856,7 @@ export const createGhidraProviderClient = (input: {
|
||||
normalized,
|
||||
input.value.pattern,
|
||||
);
|
||||
executionMetadata = { ...executionMetadata, rawResult: called.value };
|
||||
observation = { ...observation, rawResult: called.value };
|
||||
}
|
||||
if (
|
||||
operation === "inspect_native_data_type" &&
|
||||
@@ -1008,10 +881,7 @@ export const createGhidraProviderClient = (input: {
|
||||
const metadata = nativeAotOwner?.readTypeDetail(address);
|
||||
if (metadata !== undefined) {
|
||||
// Preserve the database reply independently of the derived overlay.
|
||||
executionMetadata = {
|
||||
...executionMetadata,
|
||||
rawResult: called.value,
|
||||
};
|
||||
observation = { ...observation, rawResult: called.value };
|
||||
normalized = jsonValueSchema.parse({
|
||||
...normalized,
|
||||
metadata_recovery: metadata,
|
||||
@@ -1019,7 +889,7 @@ export const createGhidraProviderClient = (input: {
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok(projectExecution(operation, normalized, executionMetadata));
|
||||
return ok(projectExecution(operation, normalized, observation));
|
||||
},
|
||||
executeIfReady: async (operation, parameters, options) => {
|
||||
if (operation !== "list_documents" && operation !== "list_procedures")
|
||||
|
||||
@@ -10,9 +10,9 @@ import { GhidraProvider } from "./GhidraProvider.js";
|
||||
// status, load-image relocation-symbol and procedure search revisions that
|
||||
// prevent reuse of snapshots with earlier results.
|
||||
const RAW_DIGESTS = {
|
||||
"6502": "4cdde67a88973466321e24d144620b9363bc6f3eed2526ccfdd52e81d204b251",
|
||||
"6502": "7850ec8d885a9b5e1e6791637b57dfcea2d4359d55754ceeaf858fc43b22293d",
|
||||
"x86-disabled-analyzer":
|
||||
"08bcaf0939ecefeba82c4b0b9cda6e7bdb975d3b8ad9b9e4aa9fdbd052958fd5",
|
||||
"55fb2b19e10d32bea95a88a097029ab2ad705b4b81dc74844662191799dbb161",
|
||||
} as const;
|
||||
|
||||
const configFor = (environment: NodeJS.ProcessEnv) => {
|
||||
|
||||
@@ -319,24 +319,14 @@ describe("Ghidra snapshot profile admission", () => {
|
||||
},
|
||||
{
|
||||
parameter: "instruction_references",
|
||||
current: "exact-address-incoming-without-synthetic-entry-v3",
|
||||
legacy: "exact-address-incoming-without-synthetic-entry-v2",
|
||||
},
|
||||
{
|
||||
parameter: "instruction_references",
|
||||
current: "exact-address-incoming-without-synthetic-entry-v3",
|
||||
current: "exact-address-incoming-without-synthetic-entry-v2",
|
||||
legacy: "exact-address-incoming-v1",
|
||||
},
|
||||
{
|
||||
parameter: "instruction_references",
|
||||
current: "exact-address-incoming-without-synthetic-entry-v3",
|
||||
current: "exact-address-incoming-without-synthetic-entry-v2",
|
||||
legacy: undefined,
|
||||
},
|
||||
{
|
||||
parameter: "function_references",
|
||||
current: "complete-body-and-entry-reference-manager-v3",
|
||||
legacy: "complete-body-and-entry-reference-manager-v2",
|
||||
},
|
||||
{
|
||||
parameter: "call_relationship_coverage",
|
||||
current: "function-body-call-coverage-v5",
|
||||
|
||||
+1
-23
@@ -341,28 +341,6 @@ export const copyAndHash = async (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The same run takes as long again, so only a longer deadline helps. Only a
|
||||
* session that outlives this request can also read the later project export.
|
||||
*/
|
||||
export const kunaTimeoutRecovery = (
|
||||
setting: string,
|
||||
subcommand: string,
|
||||
): {
|
||||
readonly remediationAction: string;
|
||||
readonly continuingCallerGuidance?: { readonly remediationAction: string };
|
||||
} => {
|
||||
const remediationAction = `Raise ${setting} in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.`;
|
||||
return subcommand === "decompile"
|
||||
? {
|
||||
remediationAction,
|
||||
continuingCallerGuidance: {
|
||||
remediationAction: `${remediationAction} Within a session, pseudocode is also read from the project export once it finishes.`,
|
||||
},
|
||||
}
|
||||
: { remediationAction };
|
||||
};
|
||||
|
||||
/** Map an owned-command failure to its typed error, keeping unproven cleanup owned. */
|
||||
const commandFailure = (
|
||||
cause: unknown,
|
||||
@@ -413,7 +391,7 @@ const commandFailure = (
|
||||
primary = new AnalysisTimeoutError(operation, ms, {
|
||||
...details,
|
||||
userMessage: `kuna ${subcommand} did not finish within ${setting} (${String(ms)} ms).`,
|
||||
...kunaTimeoutRecovery(setting, subcommand),
|
||||
remediationAction: `Raise ${setting} in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.`,
|
||||
});
|
||||
}
|
||||
// The same input overflows the same fixed buffer; waiting cannot help.
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface KunaProjectFileSpan {
|
||||
|
||||
export interface KunaProjectText {
|
||||
readonly result: string | null;
|
||||
readonly unavailableReason?: string;
|
||||
readonly files?: readonly KunaProjectFileSpan[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
@@ -314,13 +315,15 @@ export class KunaProjectReader {
|
||||
this.project.functions.map((block) => [block.address, block]),
|
||||
);
|
||||
const block = this.#blocks.get(address);
|
||||
if (block === undefined)
|
||||
if (block === undefined) {
|
||||
const reason =
|
||||
"Kuna's export decompiles code entries only; this inventory entry, such as an import slot, has no body.";
|
||||
return ok({
|
||||
result: null,
|
||||
limitations: [
|
||||
"Kuna's export decompiles code entries only; this inventory entry, such as an import slot, has no body.",
|
||||
],
|
||||
unavailableReason: reason,
|
||||
limitations: [reason],
|
||||
});
|
||||
}
|
||||
const files = [
|
||||
{
|
||||
facet: "pseudocode" as const,
|
||||
@@ -329,12 +332,18 @@ export class KunaProjectReader {
|
||||
byteLength: block.cLength,
|
||||
},
|
||||
];
|
||||
if (block.error !== null)
|
||||
if (block.error !== null) {
|
||||
const reason = `Kuna could not decompile this function: ${block.error}`;
|
||||
return ok({
|
||||
result: null,
|
||||
unavailableReason: reason,
|
||||
files,
|
||||
limitations: [`Kuna could not decompile this function: ${block.error}`],
|
||||
limitations: [
|
||||
reason,
|
||||
"To try an independent decompile, call procedure_pseudo_code with analysis: independent_fresh, or use rea decompile --analysis independent_fresh. It may use different analysis context and does not replace this observation.",
|
||||
],
|
||||
});
|
||||
}
|
||||
const text = await readSpan(
|
||||
operation,
|
||||
join(this.project.directory, this.project.manifest.files.c.path),
|
||||
|
||||
@@ -104,6 +104,9 @@ export const kunaCapabilities = (
|
||||
KUNA_OPERATIONS.map((operation) => ({
|
||||
provider: KUNA_PROVIDER_IDENTITY,
|
||||
operation,
|
||||
...(operation === "procedure_pseudo_code"
|
||||
? { independentDecompile: true as const }
|
||||
: {}),
|
||||
...(availability.status === "available"
|
||||
? ({ available: true, reason: null } as const)
|
||||
: ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Kuna release whose CLI and project layout REA verified; others report as unverified. */
|
||||
export const KUNA_VERIFIED_VERSION = "1.990";
|
||||
export const KUNA_VERIFIED_VERSION = "1.989";
|
||||
|
||||
/** Stable provider ID shared by readiness, doctor and Evidence. */
|
||||
export const KUNA_PROVIDER_ID = "kuna";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { basename } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import {
|
||||
regularFileCancellation,
|
||||
RegularFileCleanupFailure,
|
||||
@@ -501,7 +501,8 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
}),
|
||||
);
|
||||
if (
|
||||
operation === "procedure_pseudo_code" ||
|
||||
(operation === "procedure_pseudo_code" &&
|
||||
parameters.analysis !== "independent_fresh") ||
|
||||
operation === "procedure_assembly" ||
|
||||
operation === "analyze_function"
|
||||
)
|
||||
@@ -624,6 +625,30 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
// component once, including its producing file digest and byte range.
|
||||
const recordedFiles =
|
||||
files.length === 1 ? files[0] : files.length === 0 ? {} : { files };
|
||||
const rawSource =
|
||||
source === null
|
||||
? undefined
|
||||
: source.kind === "single-function"
|
||||
? { kind: "single-function" }
|
||||
: {
|
||||
kind: "project-export",
|
||||
key: source.project.key,
|
||||
directory: source.project.directory,
|
||||
...recordedFiles,
|
||||
};
|
||||
const file = files.length === 1 ? files[0] : undefined;
|
||||
const sourceDescriptor =
|
||||
source?.kind === "single-function"
|
||||
? { kind: "single-function-decompile" as const }
|
||||
: source?.kind === "project-export" && file?.file_sha256 != null
|
||||
? {
|
||||
kind: "file-range" as const,
|
||||
path: join(source.project.directory, file.file),
|
||||
sha256: file.file_sha256,
|
||||
byte_offset: file.byte_offset,
|
||||
byte_length: file.byte_length,
|
||||
}
|
||||
: undefined;
|
||||
return createAnalysisExecution(
|
||||
answer.result,
|
||||
{
|
||||
@@ -632,6 +657,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
version: installation.version,
|
||||
},
|
||||
{
|
||||
...(sourceDescriptor === undefined ? {} : { sourceDescriptor }),
|
||||
// Kuna's identity is in the analysis profile; the raw result records
|
||||
// only where file-backed text came from.
|
||||
...(source === null &&
|
||||
@@ -643,15 +669,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
...(source === null
|
||||
? {}
|
||||
: {
|
||||
source:
|
||||
source.kind === "single-function"
|
||||
? { kind: "single-function" }
|
||||
: {
|
||||
kind: "project-export",
|
||||
key: source.project.key,
|
||||
directory: source.project.directory,
|
||||
...recordedFiles,
|
||||
},
|
||||
source: rawSource,
|
||||
project_export: this.#exportState(),
|
||||
}),
|
||||
...(answer.referenceSource === undefined
|
||||
@@ -774,6 +792,33 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
limitations: classification,
|
||||
});
|
||||
case "procedure_pseudo_code": {
|
||||
if (parameters.analysis === "independent_fresh") {
|
||||
const signal =
|
||||
options.signal === undefined
|
||||
? this.#session.signal
|
||||
: AbortSignal.any([options.signal, this.#session.signal]);
|
||||
const standalone = await this.#singleFunction(
|
||||
operation,
|
||||
selected.address,
|
||||
installation,
|
||||
signal,
|
||||
true,
|
||||
);
|
||||
if (standalone === EXPORT_READY)
|
||||
throw new Error(
|
||||
"Independent decompilation unexpectedly yielded to project",
|
||||
);
|
||||
if (!standalone.ok) return standalone;
|
||||
return ok({
|
||||
result: standalone.value,
|
||||
source: { kind: "single-function" },
|
||||
locations,
|
||||
limitations: [
|
||||
"This is a fresh independent single-function decompile; it does not use the project export's whole-program prototypes.",
|
||||
...classification,
|
||||
],
|
||||
});
|
||||
}
|
||||
const pseudocode = await this.#pseudocode(
|
||||
operation,
|
||||
selected,
|
||||
@@ -1189,7 +1234,11 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
);
|
||||
if (!text.ok) return text;
|
||||
return ok({
|
||||
...text.value,
|
||||
result: text.value.result,
|
||||
limitations: text.value.limitations,
|
||||
...(text.value.files === undefined
|
||||
? {}
|
||||
: { files: text.value.files }),
|
||||
source: { kind: "project-export", project: reader.project },
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
@@ -1332,10 +1381,13 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
address: string,
|
||||
installation: KunaInstallation,
|
||||
signal: AbortSignal,
|
||||
independent = false,
|
||||
): Promise<SingleFunctionOutcome> {
|
||||
const prepared = await this.#commands.prepare();
|
||||
if (!prepared.ok) return prepared;
|
||||
const admitted = await this.#admitSingleFunction(signal);
|
||||
const admitted = independent
|
||||
? await this.#runSlots.acquire(signal)
|
||||
: await this.#admitSingleFunction(signal);
|
||||
if (admitted === EXPORT_READY) return admitted;
|
||||
if (admitted === undefined)
|
||||
return err(
|
||||
@@ -1478,7 +1530,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
pseudocode:
|
||||
text === null
|
||||
? unavailable(
|
||||
pseudocode.value.limitations.at(-1) ??
|
||||
pseudocode.value.unavailableReason ??
|
||||
"Kuna did not supply pseudocode.",
|
||||
)
|
||||
: { available: true },
|
||||
|
||||
@@ -8,10 +8,6 @@ import { readElfDirectoryFromBuffer } from "../metadata/NativeSectionDirectory.j
|
||||
import {
|
||||
compressedElfNoteFixture,
|
||||
compressedElfNoteSectionFixture,
|
||||
coreAuxvNote,
|
||||
coreFileNote,
|
||||
corePrpsinfoNote,
|
||||
coreSiginfoNote,
|
||||
elfNote,
|
||||
elfNoteFixture,
|
||||
elfNoteTablePastEnd,
|
||||
@@ -706,377 +702,3 @@ it.each([
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("decodes Linux core dump notes (NT_PRPSINFO, NT_FILE, NT_SIGINFO, NT_AUXV) for 64-bit little-endian", () => {
|
||||
const notes = Buffer.concat([
|
||||
corePrpsinfoNote({
|
||||
bits: 64,
|
||||
little: true,
|
||||
state: 0,
|
||||
sname: "R",
|
||||
pid: 4242,
|
||||
ppid: 1,
|
||||
pgrp: 4242,
|
||||
sid: 4242,
|
||||
uid: 1001,
|
||||
gid: 1002,
|
||||
fname: "test-proc",
|
||||
psargs: "/usr/bin/test-proc --daemon -v",
|
||||
}),
|
||||
coreFileNote(
|
||||
[
|
||||
{
|
||||
start: 0x400000n,
|
||||
end: 0x405000n,
|
||||
file_ofs: 0n,
|
||||
path: "/usr/bin/test-proc",
|
||||
},
|
||||
{
|
||||
start: 0x7fff0000n,
|
||||
end: 0x7fff2000n,
|
||||
file_ofs: 10n,
|
||||
path: "/lib/x86_64-linux-gnu/libc.so.6",
|
||||
},
|
||||
],
|
||||
4096,
|
||||
64,
|
||||
true,
|
||||
),
|
||||
coreSiginfoNote(11, 1, 0, true), // SIGSEGV, SEGV_MAPERR
|
||||
coreAuxvNote(
|
||||
[
|
||||
[6, 4096n], // AT_PAGESZ
|
||||
[3, 0x400040n], // AT_PHDR
|
||||
[9, 0x401000n], // AT_ENTRY
|
||||
[999, 0x1234n], // Unknown auxv tag
|
||||
],
|
||||
64,
|
||||
true,
|
||||
),
|
||||
]);
|
||||
|
||||
const bytes = elfNoteFixture({
|
||||
bits: 64,
|
||||
little: true,
|
||||
type: 4, // ET_CORE
|
||||
ownership: "program",
|
||||
notes,
|
||||
});
|
||||
|
||||
const report = parse(bytes);
|
||||
expect(report.entries).toHaveLength(4);
|
||||
|
||||
// 1. NT_PRPSINFO
|
||||
expect(report.entries[0]).toMatchObject({
|
||||
owner: "CORE",
|
||||
type: 3,
|
||||
type_name: "core_prpsinfo",
|
||||
gnu: null,
|
||||
core: {
|
||||
prpsinfo: {
|
||||
state: 0,
|
||||
pid: 4242,
|
||||
ppid: 1,
|
||||
pgrp: 4242,
|
||||
sid: 4242,
|
||||
uid: 1001,
|
||||
gid: 1002,
|
||||
filename: "test-proc",
|
||||
psargs: "/usr/bin/test-proc --daemon -v",
|
||||
},
|
||||
file: null,
|
||||
siginfo: null,
|
||||
auxv: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. NT_FILE
|
||||
expect(report.entries[1]).toMatchObject({
|
||||
owner: "CORE",
|
||||
type: 0x46494c45,
|
||||
type_name: "core_file",
|
||||
gnu: null,
|
||||
core: {
|
||||
prpsinfo: null,
|
||||
file: {
|
||||
count: 2,
|
||||
page_size: 4096,
|
||||
mappings: [
|
||||
{
|
||||
start_address: "0x400000",
|
||||
end_address: "0x405000",
|
||||
file_offset_pages: "0x0",
|
||||
path: "/usr/bin/test-proc",
|
||||
path_hex: Buffer.from("/usr/bin/test-proc").toString("hex"),
|
||||
},
|
||||
{
|
||||
start_address: "0x7fff0000",
|
||||
end_address: "0x7fff2000",
|
||||
file_offset_pages: "0xa",
|
||||
path: "/lib/x86_64-linux-gnu/libc.so.6",
|
||||
path_hex: Buffer.from("/lib/x86_64-linux-gnu/libc.so.6").toString(
|
||||
"hex",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
siginfo: null,
|
||||
auxv: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. NT_SIGINFO
|
||||
expect(report.entries[2]).toMatchObject({
|
||||
owner: "CORE",
|
||||
type: 0x53494749,
|
||||
type_name: "core_siginfo",
|
||||
gnu: null,
|
||||
core: {
|
||||
prpsinfo: null,
|
||||
file: null,
|
||||
siginfo: {
|
||||
signo: 11,
|
||||
errno: 0,
|
||||
code: 1,
|
||||
},
|
||||
auxv: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. NT_AUXV
|
||||
expect(report.entries[3]).toMatchObject({
|
||||
owner: "CORE",
|
||||
type: 6,
|
||||
type_name: "core_auxv",
|
||||
gnu: null,
|
||||
core: {
|
||||
prpsinfo: null,
|
||||
file: null,
|
||||
siginfo: null,
|
||||
auxv: [
|
||||
{ type: 6, type_name: "AT_PAGESZ", value: "0x1000" },
|
||||
{ type: 3, type_name: "AT_PHDR", value: "0x400040" },
|
||||
{ type: 9, type_name: "AT_ENTRY", value: "0x401000" },
|
||||
{ type: 999, type_name: null, value: "0x1234" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes Linux core dump notes for 32-bit big-endian", () => {
|
||||
const notes = Buffer.concat([
|
||||
corePrpsinfoNote({
|
||||
bits: 32,
|
||||
little: false,
|
||||
state: 1,
|
||||
pid: 123,
|
||||
ppid: 1,
|
||||
pgrp: 123,
|
||||
sid: 123,
|
||||
uid: 500,
|
||||
gid: 500,
|
||||
fname: "proc32",
|
||||
psargs: "proc32 arg",
|
||||
}),
|
||||
coreFileNote(
|
||||
[
|
||||
{
|
||||
start: 0x10000,
|
||||
end: 0x12000,
|
||||
file_ofs: 0,
|
||||
path: "/bin/proc32",
|
||||
},
|
||||
],
|
||||
4096,
|
||||
32,
|
||||
false,
|
||||
),
|
||||
coreAuxvNote(
|
||||
[
|
||||
[6, 4096],
|
||||
[11, 500],
|
||||
],
|
||||
32,
|
||||
false,
|
||||
),
|
||||
]);
|
||||
|
||||
const bytes = elfNoteFixture({
|
||||
bits: 32,
|
||||
little: false,
|
||||
type: 4,
|
||||
ownership: "program",
|
||||
notes,
|
||||
});
|
||||
|
||||
const report = parse(bytes);
|
||||
expect(report.entries).toHaveLength(3);
|
||||
expect(report.entries[0]?.core?.prpsinfo).toMatchObject({
|
||||
state: 1,
|
||||
pid: 123,
|
||||
uid: 500,
|
||||
gid: 500,
|
||||
filename: "proc32",
|
||||
psargs: "proc32 arg",
|
||||
});
|
||||
expect(report.entries[1]?.core?.file).toMatchObject({
|
||||
count: 1,
|
||||
page_size: 4096,
|
||||
mappings: [
|
||||
{
|
||||
start_address: "0x10000",
|
||||
end_address: "0x12000",
|
||||
file_offset_pages: "0x0",
|
||||
path: "/bin/proc32",
|
||||
path_hex: Buffer.from("/bin/proc32").toString("hex"),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(report.entries[2]?.core?.auxv).toEqual([
|
||||
{ type: 6, type_name: "AT_PAGESZ", value: "0x1000" },
|
||||
{ type: 11, type_name: "AT_UID", value: "0x1f4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tolerates malformed core notes by returning null without throwing", () => {
|
||||
const truncatedPrpsinfo = elfNote("CORE", 3, Buffer.alloc(20)); // requires 136 for 64-bit
|
||||
const truncatedFile = elfNote(
|
||||
"CORE",
|
||||
0x46494c45,
|
||||
Buffer.from("00000000000000010000000000001000", "hex"), // count 1, page_size 4096, but no map entries or filenames
|
||||
);
|
||||
const truncatedSiginfo = elfNote("CORE", 0x53494749, Buffer.alloc(4)); // requires 12 bytes
|
||||
const unterminatedAuxv = elfNote(
|
||||
"CORE",
|
||||
6,
|
||||
Buffer.from("00000000000000060000000000001000", "hex"), // AT_PAGESZ=4096 but lacks AT_NULL terminator
|
||||
);
|
||||
const truncatedAuxv = elfNote("CORE", 6, Buffer.alloc(7)); // not multiple of entry size
|
||||
const notes = Buffer.concat([
|
||||
truncatedPrpsinfo,
|
||||
truncatedFile,
|
||||
truncatedSiginfo,
|
||||
unterminatedAuxv,
|
||||
truncatedAuxv,
|
||||
]);
|
||||
|
||||
const bytes = elfNoteFixture({
|
||||
bits: 64,
|
||||
ownership: "program",
|
||||
notes,
|
||||
});
|
||||
|
||||
const report = parse(bytes);
|
||||
expect(report.entries).toHaveLength(5);
|
||||
expect(report.entries[0]?.core).toBeNull();
|
||||
expect(report.entries[0]?.type_name).toBe("core_prpsinfo");
|
||||
expect(report.entries[1]?.core).toBeNull();
|
||||
expect(report.entries[1]?.type_name).toBe("core_file");
|
||||
expect(report.entries[2]?.core).toBeNull();
|
||||
expect(report.entries[2]?.type_name).toBe("core_siginfo");
|
||||
expect(report.entries[3]?.core).toBeNull();
|
||||
expect(report.entries[3]?.type_name).toBe("core_auxv");
|
||||
expect(report.entries[4]?.core).toBeNull();
|
||||
expect(report.entries[4]?.type_name).toBe("core_auxv");
|
||||
});
|
||||
|
||||
it("decodes siginfo using target ABI for MIPS machines", () => {
|
||||
const desc = Buffer.alloc(128);
|
||||
desc.writeInt32LE(11, 0); // signo = SIGSEGV
|
||||
desc.writeInt32LE(1, 4); // code = SEGV_MAPERR (offset 4 on MIPS)
|
||||
desc.writeInt32LE(0, 8); // errno = 0 (offset 8 on MIPS)
|
||||
const mipsSiginfo = elfNote("CORE", 0x53494749, desc, true);
|
||||
const bytes = elfNoteFixture({
|
||||
bits: 32,
|
||||
machine: 8, // EM_MIPS
|
||||
ownership: "program",
|
||||
notes: mipsSiginfo,
|
||||
});
|
||||
const report = parse(bytes);
|
||||
expect(report.entries[0]?.core?.siginfo).toEqual({
|
||||
signo: 11,
|
||||
code: 1,
|
||||
errno: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves negative process IDs and signal numbers without rewriting to zero", () => {
|
||||
const prps = corePrpsinfoNote({
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
pgrp: -1,
|
||||
sid: -1,
|
||||
});
|
||||
const sig = coreSiginfoNote(-1, -2, -3);
|
||||
const bytes = elfNoteFixture({
|
||||
bits: 64,
|
||||
ownership: "program",
|
||||
notes: Buffer.concat([prps, sig]),
|
||||
});
|
||||
const report = parse(bytes);
|
||||
expect(report.entries[0]?.core?.prpsinfo).toMatchObject({
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
pgrp: -1,
|
||||
sid: -1,
|
||||
});
|
||||
expect(report.entries[1]?.core?.siginfo).toMatchObject({
|
||||
signo: -1,
|
||||
code: -2,
|
||||
errno: -3,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects 64-bit auxv tags and out-of-range page sizes with core: null without schema error", () => {
|
||||
const validAuxv = coreAuxvNote([[6, 4096n]], 64, true);
|
||||
const wideTagAuxv = Buffer.from(validAuxv);
|
||||
wideTagAuxv.writeBigUInt64LE(0x100000000n, 20);
|
||||
|
||||
const validFile = coreFileNote(
|
||||
[
|
||||
{
|
||||
start: 0x400000n,
|
||||
end: 0x401000n,
|
||||
file_ofs: 0n,
|
||||
path: "/bin/app",
|
||||
},
|
||||
],
|
||||
4096,
|
||||
64,
|
||||
true,
|
||||
);
|
||||
const widePageSizeFile = Buffer.from(validFile);
|
||||
widePageSizeFile.writeBigUInt64LE(0x100000000n, 28);
|
||||
|
||||
const bytes = elfNoteFixture({
|
||||
bits: 64,
|
||||
ownership: "program",
|
||||
notes: Buffer.concat([wideTagAuxv, widePageSizeFile]),
|
||||
});
|
||||
const report = parse(bytes);
|
||||
expect(report.entries[0]?.core).toBeNull();
|
||||
expect(report.entries[1]?.core).toBeNull();
|
||||
});
|
||||
|
||||
it("does not spend text budget on malformed or discarded NT_FILE paths", () => {
|
||||
const header = Buffer.alloc(16);
|
||||
header.writeBigUInt64LE(2n, 0); // count = 2
|
||||
header.writeBigUInt64LE(4096n, 8); // page_size = 4096
|
||||
const mappings = Buffer.alloc(48); // 2 mappings * 24 bytes
|
||||
const paths = Buffer.from(
|
||||
"/bin/app\0unterminated_path_without_null",
|
||||
"latin1",
|
||||
);
|
||||
const badFileNote = elfNote(
|
||||
"CORE",
|
||||
0x46494c45,
|
||||
Buffer.concat([header, mappings, paths]),
|
||||
);
|
||||
const bytes = elfNoteFixture({
|
||||
bits: 64,
|
||||
ownership: "program",
|
||||
notes: badFileNote,
|
||||
});
|
||||
const report = parse(bytes);
|
||||
expect(report.entries[0]?.core).toBeNull();
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
InspectElfNotesInput,
|
||||
} from "../../domain/native/elfNotes.js";
|
||||
import {
|
||||
coreNoteTypeName,
|
||||
elfObjectTypeName,
|
||||
gnuNoteTypeName,
|
||||
} from "../../domain/native/elfNotes.js";
|
||||
@@ -290,322 +289,6 @@ const decodeGnu = (
|
||||
};
|
||||
};
|
||||
|
||||
const AUXV_TYPE_NAMES: Readonly<Record<number, string>> = {
|
||||
0: "AT_NULL",
|
||||
1: "AT_IGNORE",
|
||||
2: "AT_EXECFD",
|
||||
3: "AT_PHDR",
|
||||
4: "AT_PHENT",
|
||||
5: "AT_PHNUM",
|
||||
6: "AT_PAGESZ",
|
||||
7: "AT_BASE",
|
||||
8: "AT_FLAGS",
|
||||
9: "AT_ENTRY",
|
||||
11: "AT_UID",
|
||||
12: "AT_EUID",
|
||||
13: "AT_GID",
|
||||
14: "AT_EGID",
|
||||
15: "AT_PLATFORM",
|
||||
16: "AT_HWCAP",
|
||||
17: "AT_CLKTCK",
|
||||
23: "AT_SECURE",
|
||||
25: "AT_RANDOM",
|
||||
26: "AT_HWCAP2",
|
||||
31: "AT_EXECFN",
|
||||
33: "AT_SYSINFO_EHDR",
|
||||
};
|
||||
|
||||
const hexWord = (val: bigint | number): string => `0x${val.toString(16)}`;
|
||||
|
||||
const decodeCorePrpsinfo = (
|
||||
descriptor: Buffer,
|
||||
little: boolean,
|
||||
wide: boolean,
|
||||
text: TextBudget,
|
||||
): ElfNotes["entries"][number]["core"] => {
|
||||
const minLen = wide ? 136 : 124;
|
||||
if (descriptor.length < minLen) return null;
|
||||
const u8 = (off: number) => descriptor.readUInt8(off);
|
||||
const u16 = (off: number) =>
|
||||
little ? descriptor.readUInt16LE(off) : descriptor.readUInt16BE(off);
|
||||
const u32 = (off: number) =>
|
||||
little ? descriptor.readUInt32LE(off) : descriptor.readUInt32BE(off);
|
||||
const i32 = (off: number) =>
|
||||
little ? descriptor.readInt32LE(off) : descriptor.readInt32BE(off);
|
||||
|
||||
const state = u8(0);
|
||||
const uid = wide ? u32(16) : u16(8);
|
||||
const gid = wide ? u32(20) : u16(10);
|
||||
const pid = wide ? i32(24) : i32(12);
|
||||
const ppid = wide ? i32(28) : i32(16);
|
||||
const pgrp = wide ? i32(32) : i32(20);
|
||||
const sid = wide ? i32(36) : i32(24);
|
||||
|
||||
const fnameOff = wide ? 40 : 28;
|
||||
const fnameBytes = descriptor.subarray(fnameOff, fnameOff + 16);
|
||||
const fnameEnd = fnameBytes.indexOf(0);
|
||||
const fname = (
|
||||
fnameEnd < 0 ? fnameBytes : fnameBytes.subarray(0, fnameEnd)
|
||||
).toString("latin1");
|
||||
chargeText(text, fname.length);
|
||||
|
||||
const psargsOff = wide ? 56 : 44;
|
||||
const psargsBytes = descriptor.subarray(psargsOff, psargsOff + 80);
|
||||
const psargsEnd = psargsBytes.indexOf(0);
|
||||
const psargs = (
|
||||
psargsEnd < 0 ? psargsBytes : psargsBytes.subarray(0, psargsEnd)
|
||||
).toString("latin1");
|
||||
chargeText(text, psargs.length);
|
||||
|
||||
return {
|
||||
prpsinfo: {
|
||||
state,
|
||||
pid,
|
||||
ppid,
|
||||
pgrp,
|
||||
sid,
|
||||
uid,
|
||||
gid,
|
||||
filename: fname,
|
||||
psargs,
|
||||
},
|
||||
file: null,
|
||||
siginfo: null,
|
||||
auxv: null,
|
||||
};
|
||||
};
|
||||
|
||||
const decodeCoreFile = (
|
||||
descriptor: Buffer,
|
||||
little: boolean,
|
||||
wide: boolean,
|
||||
text: TextBudget,
|
||||
): ElfNotes["entries"][number]["core"] => {
|
||||
const wordSize = wide ? 8 : 4;
|
||||
const headerSize = wordSize * 2;
|
||||
if (descriptor.length < headerSize) return null;
|
||||
|
||||
const readWord = (off: number): bigint => {
|
||||
if (wide) {
|
||||
return little
|
||||
? descriptor.readBigUInt64LE(off)
|
||||
: descriptor.readBigUInt64BE(off);
|
||||
}
|
||||
const val = little
|
||||
? descriptor.readUInt32LE(off)
|
||||
: descriptor.readUInt32BE(off);
|
||||
return BigInt(val);
|
||||
};
|
||||
|
||||
const countBig = readWord(0);
|
||||
const pageSizeBig = readWord(wordSize);
|
||||
if (countBig > 0xffffffffn || pageSizeBig > 0xffffffffn || pageSizeBig === 0n)
|
||||
return null;
|
||||
const count = Number(countBig);
|
||||
const pageSize = Number(pageSizeBig);
|
||||
|
||||
const entrySize = wordSize * 3;
|
||||
const neededBytes = BigInt(headerSize) + countBig * BigInt(entrySize);
|
||||
if (BigInt(descriptor.length) < neededBytes) return null;
|
||||
const tableBytes = Number(neededBytes);
|
||||
|
||||
const rowBytes = count * (wide ? 54 : 30);
|
||||
if (rowBytes > text.limit - text.used) {
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`ELF note text exceeds the ${String(text.limit)}-byte budget.`,
|
||||
{ boundary: "memory" },
|
||||
);
|
||||
}
|
||||
|
||||
const scanKey = `file:${String(descriptor.byteOffset)}:${String(descriptor.length)}`;
|
||||
const cachedScan = text.scans.get(scanKey);
|
||||
if (cachedScan === -1) return null;
|
||||
if (cachedScan === undefined) {
|
||||
chargeText(text, descriptor.length - tableBytes, "scanned");
|
||||
}
|
||||
|
||||
let strPos = tableBytes;
|
||||
let totalPathBytes = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (strPos >= descriptor.length) {
|
||||
text.scans.set(scanKey, -1);
|
||||
return null;
|
||||
}
|
||||
const nullIdx = descriptor.indexOf(0, strPos);
|
||||
if (nullIdx < 0) {
|
||||
text.scans.set(scanKey, -1);
|
||||
return null;
|
||||
}
|
||||
totalPathBytes += nullIdx - strPos;
|
||||
strPos = nullIdx + 1;
|
||||
}
|
||||
text.scans.set(scanKey, count);
|
||||
|
||||
const pathBytes = totalPathBytes * 3;
|
||||
chargeText(text, rowBytes + pathBytes);
|
||||
|
||||
const mappings: {
|
||||
start_address: string;
|
||||
end_address: string;
|
||||
file_offset_pages: string;
|
||||
path: string;
|
||||
path_hex: string;
|
||||
}[] = new Array(count);
|
||||
|
||||
strPos = tableBytes;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entryOff = headerSize + i * entrySize;
|
||||
const start = readWord(entryOff);
|
||||
const end = readWord(entryOff + wordSize);
|
||||
const offsetPages = readWord(entryOff + wordSize * 2);
|
||||
const nullIdx = descriptor.indexOf(0, strPos);
|
||||
const pathBuf = descriptor.subarray(strPos, nullIdx);
|
||||
strPos = nullIdx + 1;
|
||||
mappings[i] = {
|
||||
start_address: hexWord(start),
|
||||
end_address: hexWord(end),
|
||||
file_offset_pages: hexWord(offsetPages),
|
||||
path: pathBuf.toString("latin1"),
|
||||
path_hex: pathBuf.toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prpsinfo: null,
|
||||
file: {
|
||||
count,
|
||||
page_size: pageSize,
|
||||
mappings,
|
||||
},
|
||||
siginfo: null,
|
||||
auxv: null,
|
||||
};
|
||||
};
|
||||
|
||||
const decodeCoreSiginfo = (
|
||||
descriptor: Buffer,
|
||||
little: boolean,
|
||||
machine: number,
|
||||
): ElfNotes["entries"][number]["core"] => {
|
||||
if (descriptor.length < 12) return null;
|
||||
const i32 = (off: number) =>
|
||||
little ? descriptor.readInt32LE(off) : descriptor.readInt32BE(off);
|
||||
const isMips = machine === 8 || machine === 10;
|
||||
const signo = i32(0);
|
||||
const errno = isMips ? i32(8) : i32(4);
|
||||
const code = isMips ? i32(4) : i32(8);
|
||||
return {
|
||||
prpsinfo: null,
|
||||
file: null,
|
||||
siginfo: {
|
||||
signo,
|
||||
errno,
|
||||
code,
|
||||
},
|
||||
auxv: null,
|
||||
};
|
||||
};
|
||||
|
||||
const decodeCoreAuxv = (
|
||||
descriptor: Buffer,
|
||||
little: boolean,
|
||||
wide: boolean,
|
||||
text: TextBudget,
|
||||
): ElfNotes["entries"][number]["core"] => {
|
||||
const wordSize = wide ? 8 : 4;
|
||||
const entrySize = wordSize * 2;
|
||||
if (descriptor.length < entrySize || descriptor.length % entrySize !== 0)
|
||||
return null;
|
||||
const totalPairs = descriptor.length / entrySize;
|
||||
|
||||
const scanKey = `auxv:${String(descriptor.byteOffset)}:${String(descriptor.length)}`;
|
||||
let nullIndex = text.scans.get(scanKey);
|
||||
if (nullIndex === -1) return null;
|
||||
|
||||
const readWord = (off: number): bigint => {
|
||||
if (wide) {
|
||||
return little
|
||||
? descriptor.readBigUInt64LE(off)
|
||||
: descriptor.readBigUInt64BE(off);
|
||||
}
|
||||
return BigInt(
|
||||
little ? descriptor.readUInt32LE(off) : descriptor.readUInt32BE(off),
|
||||
);
|
||||
};
|
||||
|
||||
if (nullIndex === undefined) {
|
||||
chargeText(text, descriptor.length, "scanned");
|
||||
let found = false;
|
||||
for (let i = 0; i < totalPairs; i++) {
|
||||
const off = i * entrySize;
|
||||
const aTypeBig = readWord(off);
|
||||
if (aTypeBig > 0xffffffffn) {
|
||||
text.scans.set(scanKey, -1);
|
||||
return null;
|
||||
}
|
||||
if (aTypeBig === 0n) {
|
||||
found = true;
|
||||
nullIndex = i;
|
||||
for (let j = off + entrySize; j < descriptor.length; j++) {
|
||||
if (descriptor[j] !== 0) {
|
||||
text.scans.set(scanKey, -1);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found || nullIndex === undefined) {
|
||||
text.scans.set(scanKey, -1);
|
||||
return null;
|
||||
}
|
||||
text.scans.set(scanKey, nullIndex);
|
||||
}
|
||||
|
||||
const rowBytes = nullIndex * (wide ? 38 : 30);
|
||||
chargeText(text, rowBytes);
|
||||
|
||||
const auxv: { type: number; type_name: string | null; value: string }[] =
|
||||
new Array(nullIndex);
|
||||
for (let i = 0; i < nullIndex; i++) {
|
||||
const off = i * entrySize;
|
||||
const aType = Number(readWord(off));
|
||||
const aVal = readWord(off + wordSize);
|
||||
auxv[i] = {
|
||||
type: aType,
|
||||
type_name: AUXV_TYPE_NAMES[aType] ?? null,
|
||||
value: hexWord(aVal),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prpsinfo: null,
|
||||
file: null,
|
||||
siginfo: null,
|
||||
auxv,
|
||||
};
|
||||
};
|
||||
|
||||
const decodeCore = (
|
||||
owner: string,
|
||||
type: number,
|
||||
descriptor: Buffer,
|
||||
little: boolean,
|
||||
wide: boolean,
|
||||
machine: number,
|
||||
text: TextBudget,
|
||||
): ElfNotes["entries"][number]["core"] => {
|
||||
if (owner !== "CORE") return null;
|
||||
if (type === 3) return decodeCorePrpsinfo(descriptor, little, wide, text);
|
||||
if (type === 0x46494c45)
|
||||
return decodeCoreFile(descriptor, little, wide, text);
|
||||
if (type === 0x53494749)
|
||||
return decodeCoreSiginfo(descriptor, little, machine);
|
||||
if (type === 6) return decodeCoreAuxv(descriptor, little, wide, text);
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Decode PT_NOTE and SHT_NOTE tables from one in-memory ELF image. */
|
||||
export const parseElfNotes = (
|
||||
bytes: Buffer,
|
||||
@@ -756,17 +439,7 @@ export const parseElfNotes = (
|
||||
examined,
|
||||
text,
|
||||
))
|
||||
parsed.push(
|
||||
toEntry(
|
||||
note,
|
||||
"program",
|
||||
index,
|
||||
reader.little,
|
||||
wide,
|
||||
header.machine,
|
||||
text,
|
||||
),
|
||||
);
|
||||
parsed.push(toEntry(note, "program", index, reader.little, text));
|
||||
}
|
||||
}
|
||||
if (shnum !== 0) {
|
||||
@@ -821,17 +494,7 @@ export const parseElfNotes = (
|
||||
examined,
|
||||
text,
|
||||
))
|
||||
parsed.push(
|
||||
toEntry(
|
||||
note,
|
||||
"section",
|
||||
index,
|
||||
reader.little,
|
||||
wide,
|
||||
header.machine,
|
||||
text,
|
||||
),
|
||||
);
|
||||
parsed.push(toEntry(note, "section", index, reader.little, text));
|
||||
}
|
||||
}
|
||||
const absent = tables.length === 0;
|
||||
@@ -861,12 +524,9 @@ const toEntry = (
|
||||
tableKind: "program" | "section",
|
||||
tableIndex: number,
|
||||
little: boolean,
|
||||
wide: boolean,
|
||||
machine: number,
|
||||
text: TextBudget,
|
||||
): Omit<ElfNotes["entries"][number], "index"> => {
|
||||
const gnuOwner = note.owner === "GNU";
|
||||
const coreOwner = note.owner === "CORE";
|
||||
const range = `${String(note.descriptor_offset)}:${String(note.descsz)}`;
|
||||
let digest = text.hashes.get(range);
|
||||
if (digest === undefined) {
|
||||
@@ -881,11 +541,7 @@ const toEntry = (
|
||||
namesz: note.namesz,
|
||||
descsz: note.descsz,
|
||||
type: note.type,
|
||||
type_name: gnuOwner
|
||||
? gnuNoteTypeName(note.type)
|
||||
: coreOwner
|
||||
? coreNoteTypeName(note.type)
|
||||
: "unknown",
|
||||
type_name: gnuOwner ? gnuNoteTypeName(note.type) : "unknown",
|
||||
owner: note.owner,
|
||||
owner_bytes_hex: note.owner_bytes_hex,
|
||||
descriptor: {
|
||||
@@ -898,14 +554,5 @@ const toEntry = (
|
||||
: null,
|
||||
},
|
||||
gnu: decodeGnu(note.owner, note.type, note.descriptor, little, text),
|
||||
core: decodeCore(
|
||||
note.owner,
|
||||
note.type,
|
||||
note.descriptor,
|
||||
little,
|
||||
wide,
|
||||
machine,
|
||||
text,
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -11,7 +11,7 @@ const notePadding = (declared: number): number => (declared === 8 ? 8 : 4);
|
||||
const writeWord = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
value: bigint | number,
|
||||
value: number,
|
||||
bits: 32 | 64,
|
||||
little: boolean,
|
||||
): void => {
|
||||
@@ -20,8 +20,8 @@ const writeWord = (
|
||||
else bytes.writeBigUInt64BE(BigInt(value), offset);
|
||||
return;
|
||||
}
|
||||
if (little) bytes.writeUInt32LE(Number(value), offset);
|
||||
else bytes.writeUInt32BE(Number(value), offset);
|
||||
if (little) bytes.writeUInt32LE(value, offset);
|
||||
else bytes.writeUInt32BE(value, offset);
|
||||
};
|
||||
|
||||
const write16 = (
|
||||
@@ -40,8 +40,8 @@ const write32 = (
|
||||
value: number,
|
||||
little: boolean,
|
||||
): void => {
|
||||
if (little) bytes.writeUInt32LE(Number(value), offset);
|
||||
else bytes.writeUInt32BE(Number(value), offset);
|
||||
if (little) bytes.writeUInt32LE(value, offset);
|
||||
else bytes.writeUInt32BE(value, offset);
|
||||
};
|
||||
|
||||
/** Encode one ELF note, padding name/descriptor to the table alignment. */
|
||||
@@ -402,154 +402,3 @@ export const elfNoteTablePastEnd = (): Buffer => {
|
||||
writeWord(program, 48, 4, 64, true);
|
||||
return concat(header, program, note);
|
||||
};
|
||||
|
||||
export interface CorePrpsinfoOptions {
|
||||
readonly bits?: 32 | 64;
|
||||
readonly little?: boolean;
|
||||
readonly state?: number;
|
||||
readonly sname?: string;
|
||||
readonly zomb?: number;
|
||||
readonly nice?: number;
|
||||
readonly flag?: number;
|
||||
readonly uid?: number;
|
||||
readonly gid?: number;
|
||||
readonly pid?: number;
|
||||
readonly ppid?: number;
|
||||
readonly pgrp?: number;
|
||||
readonly sid?: number;
|
||||
readonly fname?: string;
|
||||
readonly psargs?: string;
|
||||
}
|
||||
|
||||
/** Linux CORE NT_PRPSINFO note (type 3). */
|
||||
export const corePrpsinfoNote = (options: CorePrpsinfoOptions = {}): Buffer => {
|
||||
const bits = options.bits ?? 64;
|
||||
const little = options.little ?? true;
|
||||
const desc = Buffer.alloc(bits === 64 ? 136 : 124);
|
||||
desc[0] = options.state ?? 0;
|
||||
desc[1] = (options.sname ?? "R").charCodeAt(0);
|
||||
desc[2] = options.zomb ?? 0;
|
||||
desc[3] = options.nice ?? 0;
|
||||
if (bits === 64) {
|
||||
if (little) desc.writeBigUInt64LE(BigInt(options.flag ?? 0), 8);
|
||||
else desc.writeBigUInt64BE(BigInt(options.flag ?? 0), 8);
|
||||
write32(desc, 16, options.uid ?? 1000, little);
|
||||
write32(desc, 20, options.gid ?? 1000, little);
|
||||
if (little) {
|
||||
desc.writeInt32LE(options.pid ?? 1234, 24);
|
||||
desc.writeInt32LE(options.ppid ?? 1, 28);
|
||||
desc.writeInt32LE(options.pgrp ?? 1234, 32);
|
||||
desc.writeInt32LE(options.sid ?? 1234, 36);
|
||||
} else {
|
||||
desc.writeInt32BE(options.pid ?? 1234, 24);
|
||||
desc.writeInt32BE(options.ppid ?? 1, 28);
|
||||
desc.writeInt32BE(options.pgrp ?? 1234, 32);
|
||||
desc.writeInt32BE(options.sid ?? 1234, 36);
|
||||
}
|
||||
const fname = Buffer.from(`${options.fname ?? "app"}\0`, "latin1");
|
||||
fname.copy(desc, 40, 0, Math.min(fname.length, 16));
|
||||
const psargs = Buffer.from(
|
||||
`${options.psargs ?? "./app --flag"}\0`,
|
||||
"latin1",
|
||||
);
|
||||
psargs.copy(desc, 56, 0, Math.min(psargs.length, 80));
|
||||
} else {
|
||||
write32(desc, 4, options.flag ?? 0, little);
|
||||
write16(desc, 8, options.uid ?? 1000, little);
|
||||
write16(desc, 10, options.gid ?? 1000, little);
|
||||
if (little) {
|
||||
desc.writeInt32LE(options.pid ?? 1234, 12);
|
||||
desc.writeInt32LE(options.ppid ?? 1, 16);
|
||||
desc.writeInt32LE(options.pgrp ?? 1234, 20);
|
||||
desc.writeInt32LE(options.sid ?? 1234, 24);
|
||||
} else {
|
||||
desc.writeInt32BE(options.pid ?? 1234, 12);
|
||||
desc.writeInt32BE(options.ppid ?? 1, 16);
|
||||
desc.writeInt32BE(options.pgrp ?? 1234, 20);
|
||||
desc.writeInt32BE(options.sid ?? 1234, 24);
|
||||
}
|
||||
const fname = Buffer.from(`${options.fname ?? "app"}\0`, "latin1");
|
||||
fname.copy(desc, 28, 0, Math.min(fname.length, 16));
|
||||
const psargs = Buffer.from(
|
||||
`${options.psargs ?? "./app --flag"}\0`,
|
||||
"latin1",
|
||||
);
|
||||
psargs.copy(desc, 44, 0, Math.min(psargs.length, 80));
|
||||
}
|
||||
return elfNote("CORE", 3, desc, little);
|
||||
};
|
||||
|
||||
export interface CoreFileMapping {
|
||||
readonly start: bigint | number;
|
||||
readonly end: bigint | number;
|
||||
readonly file_ofs: bigint | number;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/** Linux CORE NT_FILE note (type 0x46494c45). */
|
||||
export const coreFileNote = (
|
||||
mappings: readonly CoreFileMapping[],
|
||||
pageSize = 4096,
|
||||
bits: 32 | 64 = 64,
|
||||
little = true,
|
||||
): Buffer => {
|
||||
const wordBytes = bits === 64 ? 8 : 4;
|
||||
const headerBytes = 2 * wordBytes;
|
||||
const mapEntryBytes = 3 * wordBytes;
|
||||
const filenames = Buffer.concat(
|
||||
mappings.map((m) => Buffer.from(`${m.path}\0`, "latin1")),
|
||||
);
|
||||
const desc = Buffer.alloc(
|
||||
headerBytes + mappings.length * mapEntryBytes + filenames.length,
|
||||
);
|
||||
writeWord(desc, 0, mappings.length, bits, little);
|
||||
writeWord(desc, wordBytes, pageSize, bits, little);
|
||||
let offset = headerBytes;
|
||||
for (const m of mappings) {
|
||||
writeWord(desc, offset, m.start, bits, little);
|
||||
writeWord(desc, offset + wordBytes, m.end, bits, little);
|
||||
writeWord(desc, offset + 2 * wordBytes, m.file_ofs, bits, little);
|
||||
offset += mapEntryBytes;
|
||||
}
|
||||
filenames.copy(desc, offset);
|
||||
return elfNote("CORE", 0x46494c45, desc, little);
|
||||
};
|
||||
|
||||
/** Linux CORE NT_SIGINFO note (type 0x53494749). */
|
||||
export const coreSiginfoNote = (
|
||||
signo = 11,
|
||||
code = 1,
|
||||
errno = 0,
|
||||
little = true,
|
||||
): Buffer => {
|
||||
const desc = Buffer.alloc(128);
|
||||
if (little) {
|
||||
desc.writeInt32LE(signo, 0);
|
||||
desc.writeInt32LE(errno, 4);
|
||||
desc.writeInt32LE(code, 8);
|
||||
} else {
|
||||
desc.writeInt32BE(signo, 0);
|
||||
desc.writeInt32BE(errno, 4);
|
||||
desc.writeInt32BE(code, 8);
|
||||
}
|
||||
return elfNote("CORE", 0x53494749, desc, little);
|
||||
};
|
||||
|
||||
/** Linux CORE NT_AUXV note (type 6). */
|
||||
export const coreAuxvNote = (
|
||||
entries: readonly [key: number, value: bigint | number][],
|
||||
bits: 32 | 64 = 64,
|
||||
little = true,
|
||||
): Buffer => {
|
||||
const wordBytes = bits === 64 ? 8 : 4;
|
||||
const desc = Buffer.alloc((entries.length + 1) * 2 * wordBytes);
|
||||
let offset = 0;
|
||||
for (const [key, value] of entries) {
|
||||
writeWord(desc, offset, key, bits, little);
|
||||
writeWord(desc, offset + wordBytes, value, bits, little);
|
||||
offset += 2 * wordBytes;
|
||||
}
|
||||
writeWord(desc, offset, 0, bits, little);
|
||||
writeWord(desc, offset + wordBytes, 0, bits, little);
|
||||
return elfNote("CORE", 6, desc, little);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Source-owned static provider identity and public operation names. */
|
||||
export const OBJDUMP_PROVIDER_IDENTITY = {
|
||||
id: "objdump.command",
|
||||
name: "objdump command",
|
||||
id: "gnu.objdump",
|
||||
name: "GNU objdump",
|
||||
version: null,
|
||||
} as const;
|
||||
@@ -207,9 +207,8 @@ export const registerEnhancedTools = (
|
||||
server.registerTool(
|
||||
traceFeature.name,
|
||||
toolRegistrationOptions(traceFeature),
|
||||
({ detail, ...input }, context) =>
|
||||
(input, context) =>
|
||||
executeEnhancedTool(registration, traceFeature, {
|
||||
detail,
|
||||
validatedCall: { name: "trace_feature", input },
|
||||
context,
|
||||
}),
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"retains wrapped protocol.handle source registrations through %s",
|
||||
async (surface, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-electron-handle-presence-");
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ main: "main.cjs" }),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "main.cjs"),
|
||||
`
|
||||
const { protocol, session } = require("electron");
|
||||
protocol.handle("module-omitted");
|
||||
protocol.handle("module-null", null);
|
||||
protocol.handle("module-undefined", void 0);
|
||||
session.defaultSession.protocol.handle("session-omitted");
|
||||
session.defaultSession.protocol.handle("session-null", null);
|
||||
session.defaultSession.protocol.handle("session-undefined", void 0);
|
||||
protocol.registerStringProtocol("legacy-omitted");
|
||||
protocol.registerStringProtocol("legacy-null", null);
|
||||
protocol.registerStringProtocol("legacy-undefined", void 0);
|
||||
throw new Error("analysis fixture must remain inert");
|
||||
`,
|
||||
);
|
||||
let document: unknown;
|
||||
if (surface === "CLI") {
|
||||
const response = await cli.run({
|
||||
arguments: ["analyze-javascript-application", root, "--json"],
|
||||
});
|
||||
expect(response.exitCode, response.stderr).toBe(0);
|
||||
document = response.json;
|
||||
} else {
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const response = await call("analyze_javascript_application", {
|
||||
input_path: root,
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
document = response.structuredContent;
|
||||
}
|
||||
const result = z
|
||||
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||
.parse(document).normalized_result;
|
||||
expect(result.summary.protocol_registrations).toBe(6);
|
||||
const schemes = result.graph.nodes.filter(
|
||||
({ kind }) => kind === "url-scheme",
|
||||
);
|
||||
expect(
|
||||
schemes.map((node) => node.observations[0]?.label).toSorted(),
|
||||
).toEqual([
|
||||
"module-null",
|
||||
"module-omitted",
|
||||
"module-undefined",
|
||||
"session-null",
|
||||
"session-omitted",
|
||||
"session-undefined",
|
||||
]);
|
||||
for (const scheme of schemes) {
|
||||
const observation = scheme.observations[0];
|
||||
expect(observation?.properties).toMatchObject({
|
||||
api: "handle",
|
||||
role: "handler",
|
||||
resolution: "literal",
|
||||
session: observation?.label?.startsWith("session-")
|
||||
? "default-session"
|
||||
: "default-protocol",
|
||||
});
|
||||
expect(observation?.evidence).toMatchObject({
|
||||
authority: "ast-static-analysis",
|
||||
state: "observed",
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("successful registration"),
|
||||
]),
|
||||
});
|
||||
}
|
||||
expect(
|
||||
result.graph.edges.filter((edge) => edge.relation === "handles"),
|
||||
).toHaveLength(6);
|
||||
},
|
||||
);
|
||||
@@ -1,113 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
elfNote,
|
||||
elfNoteFixture,
|
||||
} from "../../src/native/elf/ElfNotes.fixture.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
// Literal big-endian descriptors keep the expected values independent of the
|
||||
// fixture's integer writers and the parser's byte-order branch.
|
||||
const knownAbi = Buffer.from("00000000010203041020304080010203", "hex");
|
||||
const unknownAbi = Buffer.from("01000000010203041020304080010203", "hex");
|
||||
const extendedAbi = Buffer.concat([knownAbi, Buffer.from("01020304", "hex")]);
|
||||
const shortAbi = knownAbi.subarray(0, 12);
|
||||
const descriptorHash = (bytes: Buffer): string =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
for (const { bits, ownership, machine } of [
|
||||
{ bits: 32, ownership: "program", machine: 20 },
|
||||
{ bits: 64, ownership: "section", machine: 21 },
|
||||
] as const)
|
||||
cliTest(
|
||||
`preserves big-endian ELF${String(bits)} ABI facts through CLI and stdio MCP`,
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-elf-abi-endian-");
|
||||
const path = join(root, "selected.elf");
|
||||
const descriptors = [
|
||||
knownAbi,
|
||||
unknownAbi,
|
||||
shortAbi,
|
||||
extendedAbi,
|
||||
knownAbi,
|
||||
];
|
||||
const notes = Buffer.concat([
|
||||
...descriptors
|
||||
.slice(0, 4)
|
||||
.map((bytes) => elfNote("GNU", 1, bytes, false)),
|
||||
elfNote("OTHER", 1, knownAbi, false),
|
||||
]);
|
||||
const bytes = elfNoteFixture({
|
||||
bits,
|
||||
ownership,
|
||||
machine,
|
||||
little: false,
|
||||
notes,
|
||||
});
|
||||
await writeFile(path, bytes);
|
||||
|
||||
const output = await cli.sharedCliRun!({
|
||||
arguments: ["inspect-elf-notes", path, "--json"],
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
const call = await cli.sharedMcpCall!();
|
||||
const response = await call("inspect_elf_notes", { path });
|
||||
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
||||
|
||||
const schema = toolContract("inspect_elf_notes").outputSchema;
|
||||
const reports = [output.json, response.structuredContent].map((value) => {
|
||||
const evidence = schema.parse(value);
|
||||
expect(evidence.subject).toMatchObject({
|
||||
local_path: path,
|
||||
digest: { sha256: descriptorHash(bytes) },
|
||||
});
|
||||
const report = evidence.normalized_result;
|
||||
expect(report).toMatchObject({
|
||||
format: `elf${String(bits)}`,
|
||||
byte_order: "big",
|
||||
machine,
|
||||
coverage: {
|
||||
status: "complete",
|
||||
examined_tables: 1,
|
||||
examined_entries: 5,
|
||||
},
|
||||
});
|
||||
expect(report.entries).toHaveLength(5);
|
||||
expect(report.entries[0]?.gnu?.abi_tag).toEqual({
|
||||
os: 0,
|
||||
os_name: "linux",
|
||||
major: 0x01020304,
|
||||
minor: 0x10203040,
|
||||
subminor: 0x80010203,
|
||||
});
|
||||
expect(report.entries[1]?.gnu?.abi_tag).toEqual({
|
||||
os: 0x01000000,
|
||||
os_name: null,
|
||||
major: 0x01020304,
|
||||
minor: 0x10203040,
|
||||
subminor: 0x80010203,
|
||||
});
|
||||
// Unexpected lengths stay raw; no ABI values are invented or truncated.
|
||||
for (const index of [2, 3])
|
||||
expect(report.entries[index]?.gnu?.abi_tag).toBeNull();
|
||||
expect(report.entries[4]).toMatchObject({
|
||||
owner: "OTHER",
|
||||
type_name: "unknown",
|
||||
gnu: null,
|
||||
});
|
||||
for (const [index, descriptor] of descriptors.entries())
|
||||
expect(report.entries[index]?.descriptor).toMatchObject({
|
||||
bytes: descriptor.length,
|
||||
sha256: descriptorHash(descriptor),
|
||||
});
|
||||
return report;
|
||||
});
|
||||
expect(reports[0]).toEqual(reports[1]);
|
||||
expect((await readFile(path)).equals(bytes)).toBe(true);
|
||||
},
|
||||
);
|
||||
@@ -1,185 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
coreAuxvNote,
|
||||
coreFileNote,
|
||||
corePrpsinfoNote,
|
||||
coreSiginfoNote,
|
||||
elfNoteFixture,
|
||||
} from "../../src/native/elf/ElfNotes.fixture.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
const descriptorHash = (bytes: Buffer): string =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
const buildCoreNotes = (): Buffer =>
|
||||
Buffer.concat([
|
||||
corePrpsinfoNote({
|
||||
bits: 64,
|
||||
little: true,
|
||||
state: 0,
|
||||
sname: "R",
|
||||
pid: 31337,
|
||||
ppid: 1,
|
||||
pgrp: 31337,
|
||||
sid: 31337,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
fname: "target-service",
|
||||
psargs: "/opt/target-service --worker 1",
|
||||
}),
|
||||
coreFileNote(
|
||||
[
|
||||
{
|
||||
start: 0x555555554000n,
|
||||
end: 0x555555558000n,
|
||||
file_ofs: 0n,
|
||||
path: "/opt/target-service",
|
||||
},
|
||||
{
|
||||
start: 0x7ffff7dc0000n,
|
||||
end: 0x7ffff7de0000n,
|
||||
file_ofs: 0n,
|
||||
path: "/lib/x86_64-linux-gnu/libc.so.6",
|
||||
},
|
||||
],
|
||||
4096,
|
||||
64,
|
||||
true,
|
||||
),
|
||||
coreSiginfoNote(11, 1, 0, true),
|
||||
coreAuxvNote(
|
||||
[
|
||||
[6, 4096n],
|
||||
[3, 0x555555554040n],
|
||||
],
|
||||
64,
|
||||
true,
|
||||
),
|
||||
]);
|
||||
|
||||
const assertCoreEntries = (entries: unknown[]): void => {
|
||||
expect(entries).toHaveLength(4);
|
||||
expect(entries[0]).toMatchObject({
|
||||
owner: "CORE",
|
||||
type: 3,
|
||||
type_name: "core_prpsinfo",
|
||||
gnu: null,
|
||||
core: {
|
||||
prpsinfo: {
|
||||
state: 0,
|
||||
pid: 31337,
|
||||
ppid: 1,
|
||||
pgrp: 31337,
|
||||
sid: 31337,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
filename: "target-service",
|
||||
psargs: "/opt/target-service --worker 1",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(entries[1]).toMatchObject({
|
||||
owner: "CORE",
|
||||
type: 0x46494c45,
|
||||
type_name: "core_file",
|
||||
gnu: null,
|
||||
core: {
|
||||
file: {
|
||||
count: 2,
|
||||
page_size: 4096,
|
||||
mappings: [
|
||||
{
|
||||
start_address: "0x555555554000",
|
||||
end_address: "0x555555558000",
|
||||
file_offset_pages: "0x0",
|
||||
path: "/opt/target-service",
|
||||
path_hex: Buffer.from("/opt/target-service").toString("hex"),
|
||||
},
|
||||
{
|
||||
start_address: "0x7ffff7dc0000",
|
||||
end_address: "0x7ffff7de0000",
|
||||
file_offset_pages: "0x0",
|
||||
path: "/lib/x86_64-linux-gnu/libc.so.6",
|
||||
path_hex: Buffer.from("/lib/x86_64-linux-gnu/libc.so.6").toString(
|
||||
"hex",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(entries[2]).toMatchObject({
|
||||
owner: "CORE",
|
||||
type: 0x53494749,
|
||||
type_name: "core_siginfo",
|
||||
gnu: null,
|
||||
core: {
|
||||
siginfo: { signo: 11, errno: 0, code: 1 },
|
||||
},
|
||||
});
|
||||
expect(entries[3]).toMatchObject({
|
||||
owner: "CORE",
|
||||
type: 6,
|
||||
type_name: "core_auxv",
|
||||
gnu: null,
|
||||
core: {
|
||||
auxv: [
|
||||
{ type: 6, type_name: "AT_PAGESZ", value: "0x1000" },
|
||||
{ type: 3, type_name: "AT_PHDR", value: "0x555555554040" },
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
cliTest(
|
||||
"decodes Linux core dump notes through CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-elf-core-notes-");
|
||||
const path = join(root, "core.elf");
|
||||
const notes = buildCoreNotes();
|
||||
const bytes = elfNoteFixture({
|
||||
bits: 64,
|
||||
little: true,
|
||||
type: 4,
|
||||
ownership: "program",
|
||||
notes,
|
||||
});
|
||||
await writeFile(path, bytes);
|
||||
|
||||
const output = await cli.sharedCliRun!({
|
||||
arguments: ["inspect-elf-notes", path, "--json"],
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
|
||||
const call = await cli.sharedMcpCall!();
|
||||
const response = await call("inspect_elf_notes", { path });
|
||||
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
||||
|
||||
const schema = toolContract("inspect_elf_notes").outputSchema;
|
||||
for (const raw of [output.json, response.structuredContent]) {
|
||||
const evidence = schema.parse(raw);
|
||||
expect(evidence.subject).toMatchObject({
|
||||
local_path: path,
|
||||
digest: { sha256: descriptorHash(bytes) },
|
||||
});
|
||||
const report = evidence.normalized_result;
|
||||
expect(report).toMatchObject({
|
||||
format: "elf64",
|
||||
byte_order: "little",
|
||||
machine: 62,
|
||||
coverage: {
|
||||
status: "complete",
|
||||
examined_tables: 1,
|
||||
examined_entries: 4,
|
||||
},
|
||||
});
|
||||
assertCoreEntries(report.entries);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -89,9 +89,6 @@ it("summary delivery returns the compact view and writes a bundle inspect-analys
|
||||
evidence_bundle: { path: string; bytes: number };
|
||||
};
|
||||
expect(h.runDirectAnalysis).toHaveBeenCalledOnce();
|
||||
expect(h.runDirectAnalysis.mock.calls[0]?.[3]).toMatchObject({
|
||||
includeEvidenceSources: true,
|
||||
});
|
||||
expect(out.summary.normalized_result.kind).toBe("summary");
|
||||
expect(typeof out.summary.normalized_result.parent_evidence_id).toBe(
|
||||
"string",
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Cli } from "incur";
|
||||
import pino from "pino";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisProvider,
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js";
|
||||
import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js";
|
||||
import { registerCoreBinaryCommands } from "../../../src/cli/coreBinaryCommands.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const identity = { id: "fixture", name: "Fixture", version: "1" } as const;
|
||||
const effects = {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
};
|
||||
|
||||
const verifyReplay = async (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
path: string,
|
||||
snapshot: string,
|
||||
root: string,
|
||||
originalBundle: ReturnType<typeof parseEvidenceBundle>,
|
||||
traceId: string | undefined,
|
||||
) => {
|
||||
const output = join(root, "replay-evidence.json");
|
||||
let stdout = "";
|
||||
await cli.serve(
|
||||
[
|
||||
"trace",
|
||||
path,
|
||||
"goal",
|
||||
"--provider",
|
||||
"fixture",
|
||||
"--snapshot",
|
||||
snapshot,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence_output",
|
||||
output,
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
env: {},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
exit: () => undefined,
|
||||
},
|
||||
);
|
||||
const response = JSON.parse(stdout);
|
||||
expect(response, JSON.stringify(response)).toHaveProperty("summary");
|
||||
const bundle = parseEvidenceBundle(
|
||||
JSON.parse(await readFile(output, "utf8")),
|
||||
);
|
||||
expect(response.summary.normalized_result.parent_evidence_id).toBe(traceId);
|
||||
expect(bundle.records.map((record) => record.evidence_id).sort()).toEqual(
|
||||
originalBundle.records.map((record) => record.evidence_id).sort(),
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ detail: "summary", missing: false },
|
||||
{ detail: "complete", missing: false },
|
||||
{ detail: "summary", missing: true },
|
||||
{ detail: "summary", missing: false, replay: true },
|
||||
])(
|
||||
"publishes $detail trace only when its source closure is available (missing=$missing, replay=$replay)",
|
||||
async ({ detail, missing, replay }) => {
|
||||
const root = await createTestTempDirectory("rea-native-trace-cli-source-");
|
||||
const path = join(root, "fixture.hop");
|
||||
const output = join(root, "evidence.json");
|
||||
const snapshot = join(root, "snapshot.json");
|
||||
let allowProvider = true;
|
||||
await writeFile(path, "synthetic analysis database");
|
||||
const history = createEvidence(undefined, identity, {
|
||||
operation: "health",
|
||||
parameters: { facet: "historical-project-analysis-reports" },
|
||||
result: { origin: "caller-project" },
|
||||
confidence: "observed",
|
||||
...(missing ? { evidenceLinks: ["ev_" + "a".repeat(64)] } : {}),
|
||||
});
|
||||
const provider: AnalysisProvider = {
|
||||
identity: () => identity,
|
||||
capabilities: () =>
|
||||
(
|
||||
[
|
||||
"search_strings",
|
||||
"search_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
] as const
|
||||
).map((operation) => ({
|
||||
provider: identity,
|
||||
operation,
|
||||
available: true,
|
||||
reason: null,
|
||||
cachePolicy: replay ? "snapshot" : "live",
|
||||
limitations: [],
|
||||
effects,
|
||||
})),
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
|
||||
createClient: () => {
|
||||
if (!allowProvider)
|
||||
throw new Error("Snapshot replay reopened the provider");
|
||||
return {
|
||||
execute: async (operation) => {
|
||||
const value =
|
||||
operation === "search_procedures"
|
||||
? [{ address: "0x1000", value: "goal" }]
|
||||
: operation === "xrefs"
|
||||
? ["0x1010"]
|
||||
: operation === "resolve_containing_procedure"
|
||||
? {
|
||||
query_address: "0x1010",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}
|
||||
: operation === "search_strings"
|
||||
? []
|
||||
: null;
|
||||
return ok(
|
||||
createAnalysisExecution(value, identity, {
|
||||
sourceEvidence: [history],
|
||||
}),
|
||||
);
|
||||
},
|
||||
close: async () => ok(null),
|
||||
};
|
||||
},
|
||||
};
|
||||
const createSession = () => createTestBinarySession(provider);
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: createSession,
|
||||
createNativeDispatchBinarySession: createSession,
|
||||
createArtifactBinarySession: createSession,
|
||||
createNativeBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
};
|
||||
const cli = Cli.create("rea-native-trace-cli-source", { sync: false });
|
||||
registerCoreBinaryCommands(
|
||||
cli,
|
||||
pino({ enabled: false }),
|
||||
(target, tool, args, options) =>
|
||||
runDirectAnalysis(dependencies, target, tool, args, options),
|
||||
{},
|
||||
);
|
||||
let stdout = "";
|
||||
await cli.serve(
|
||||
[
|
||||
"trace",
|
||||
path,
|
||||
"goal",
|
||||
"--provider",
|
||||
"fixture",
|
||||
...(replay ? ["--snapshot", snapshot] : []),
|
||||
"--detail",
|
||||
detail,
|
||||
"--evidence_output",
|
||||
output,
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
env: {},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
exit: () => undefined,
|
||||
},
|
||||
);
|
||||
const response = JSON.parse(stdout);
|
||||
if (missing) {
|
||||
expect(response).toMatchObject({
|
||||
code: "evidence_integrity_mismatch",
|
||||
details: { reason: "missing", evidence_id: "ev_" + "a".repeat(64) },
|
||||
});
|
||||
await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} else {
|
||||
const bundle = parseEvidenceBundle(
|
||||
JSON.parse(await readFile(output, "utf8")),
|
||||
);
|
||||
const trace = bundle.records.find(
|
||||
(record) => record.operation === "trace_feature",
|
||||
);
|
||||
expect(
|
||||
detail === "summary"
|
||||
? response.summary.normalized_result.parent_evidence_id
|
||||
: response.evidence_id,
|
||||
).toBe(trace?.evidence_id);
|
||||
expect(trace?.evidence_links).toContain(history.evidence_id);
|
||||
expect(
|
||||
bundle.records.some(
|
||||
(record) => record.evidence_id === history.evidence_id,
|
||||
),
|
||||
).toBe(true);
|
||||
if (replay) {
|
||||
allowProvider = false;
|
||||
await verifyReplay(
|
||||
cli,
|
||||
path,
|
||||
snapshot,
|
||||
root,
|
||||
bundle,
|
||||
trace?.evidence_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,71 +0,0 @@
|
||||
import { expect } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"does not attribute a caller-selected command to GNU objdump",
|
||||
async ({ cli }) => {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"inspect-with-objdump",
|
||||
process.execPath,
|
||||
"file_headers",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
environment: { REA_OBJDUMP_COMMAND: process.execPath },
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
provider: {
|
||||
id: "objdump.command",
|
||||
name: "objdump command",
|
||||
version: null,
|
||||
},
|
||||
parameters: { provider_executable: process.execPath },
|
||||
normalized_result: {
|
||||
exit_code: expect.any(Number),
|
||||
completion_status: "complete",
|
||||
},
|
||||
});
|
||||
|
||||
const architectures = await cli.run({
|
||||
arguments: ["list-objdump-architectures", "--format", "json"],
|
||||
environment: { REA_OBJDUMP_COMMAND: process.execPath },
|
||||
input: "",
|
||||
});
|
||||
expect(architectures.exitCode).toBe(0);
|
||||
expect(architectures.json).toMatchObject({
|
||||
provider: {
|
||||
id: "objdump.command",
|
||||
name: "objdump command",
|
||||
version: null,
|
||||
},
|
||||
subject: null,
|
||||
});
|
||||
|
||||
const missing = join(tmpdir(), `missing-objdump-${randomUUID()}`);
|
||||
const failed = await cli.run({
|
||||
arguments: [
|
||||
"inspect-with-objdump",
|
||||
process.execPath,
|
||||
"file_headers",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
environment: { REA_OBJDUMP_COMMAND: missing },
|
||||
});
|
||||
expect(failed.exitCode).toBe(1);
|
||||
expect(failed.json).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
provider_id: "objdump.command",
|
||||
diagnostics: { command: missing },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -250,7 +250,7 @@ it("promotes only complete production cleanup-only Evidence after CLI cleanup re
|
||||
const result = await runObjdump(service, path);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
provider: { id: "objdump.command" },
|
||||
provider: { id: "gnu.objdump" },
|
||||
normalized_result: { completion_status: "complete" },
|
||||
});
|
||||
expect(producedCleanupFailure?.cleanupOnly).toBe(true);
|
||||
|
||||
@@ -446,34 +446,6 @@ describe("binary target I/O: app plist permissions and decoding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary target I/O: OpenStep app plist escapes", () => {
|
||||
it.each([
|
||||
'{ CFBundleExecutable = "M\\U0061in"; }',
|
||||
'{ CFBundleExecutable = "M\\141in"; }',
|
||||
'{ "CFBundle\\U0045xecutable" = "Main"; }',
|
||||
])(
|
||||
"resolves an OpenStep bundle executable with escaped keys or values in %s",
|
||||
async (plistContent) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-app-openstep-escapes-",
|
||||
);
|
||||
const app = join(directory, "Probe.app");
|
||||
const programs = join(app, "Contents", "MacOS");
|
||||
await mkdir(programs, { recursive: true });
|
||||
await writeFile(join(app, "Contents", "Info.plist"), plistContent);
|
||||
await writeFile(join(programs, "Main"), "binary");
|
||||
const result = await resolveAppBundleExecutable(app);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
executable: join(programs, "Main"),
|
||||
infoPlist: join(app, "Contents", "Info.plist"),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("binary target I/O: package classification", () => {
|
||||
it("classifies ZIP package families and text artifacts without Hopper", async () => {
|
||||
const directory = await createTestTempDirectory("rea-artifact-target-");
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
|
||||
import { publishCliAnalysisEvidence } from "../../../src/cli/analysisEvidenceDelivery.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
@@ -132,19 +131,6 @@ const expectWorkflowQuestionsRetained = (
|
||||
);
|
||||
};
|
||||
|
||||
const expectSourceSnapshotRetained = (snapshot: AnalysisSnapshot) => {
|
||||
expect(snapshot.entries).toHaveLength(1);
|
||||
const parent = snapshot.evidence_bundle.records.find(
|
||||
(record) => record.operation === "list_strings",
|
||||
);
|
||||
expect(parent?.evidence_links).toHaveLength(1);
|
||||
expect(snapshot.evidence_bundle.records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ evidence_id: parent?.evidence_links[0] }),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
interface SnapshotProviderTrace {
|
||||
readonly starts: string[];
|
||||
readonly queries: Array<{
|
||||
@@ -159,8 +145,6 @@ interface SnapshotProviderOptions {
|
||||
readonly identity?: ProviderIdentity;
|
||||
readonly supportedOperations?: readonly CapabilityDescriptor["operation"][];
|
||||
readonly limitations?: readonly string[];
|
||||
readonly sourceEvidence?: boolean;
|
||||
readonly sourceMissingLink?: boolean;
|
||||
}
|
||||
|
||||
const makeProvider = (
|
||||
@@ -197,7 +181,7 @@ const makeProvider = (
|
||||
trace.resolutions.push(profile.digest);
|
||||
return ok({ profile });
|
||||
},
|
||||
createClient: (target) => {
|
||||
createClient: () => {
|
||||
trace.starts.push("start");
|
||||
return {
|
||||
execute: async (operation, parameters) => {
|
||||
@@ -258,20 +242,6 @@ const makeProvider = (
|
||||
analysisProfile: profile,
|
||||
rawResult: result,
|
||||
limitations: options.limitations ?? [],
|
||||
...(options.sourceEvidence
|
||||
? {
|
||||
sourceEvidence: [
|
||||
createEvidence(target, profile.provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: "fixture source",
|
||||
...(options.sourceMissingLink
|
||||
? { evidenceLinks: [`ev_${"0".repeat(64)}`] }
|
||||
: {}),
|
||||
}),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -416,18 +386,13 @@ const withEarlierHistoricalEvidence = (
|
||||
};
|
||||
|
||||
describe("snapshot output cleanup lifecycle", () => {
|
||||
it.each([
|
||||
{ failureKind: "cleanup", sourceEvidence: false },
|
||||
{ failureKind: "cancelled", sourceEvidence: false },
|
||||
{ failureKind: "cleanup", sourceEvidence: true },
|
||||
{ failureKind: "cancelled", sourceEvidence: true },
|
||||
] as const)(
|
||||
"preserves a committed snapshot after $failureKind finalization with source=$sourceEvidence",
|
||||
async ({ failureKind, sourceEvidence }) => {
|
||||
it.each(["cleanup", "cancelled"] as const)(
|
||||
"preserves the committed snapshot receipt after %s finalization and retry",
|
||||
async (failureKind) => {
|
||||
const fixture = await createSnapshotReplayFixture(
|
||||
"rea-snapshot-close-failure-",
|
||||
);
|
||||
const provider = fixture.createProvider({ sourceEvidence });
|
||||
const provider = fixture.createProvider();
|
||||
const failure =
|
||||
failureKind === "cleanup"
|
||||
? new ProviderCleanupError(
|
||||
@@ -458,9 +423,7 @@ describe("snapshot output cleanup lifecycle", () => {
|
||||
try {
|
||||
const opened = await session.open(fixture.path);
|
||||
if (!opened.ok) throw opened.error;
|
||||
const analyzed = sourceEvidence
|
||||
? await session.execute("list_strings", { document: "fixture" })
|
||||
: await session.execute("list_segments", {});
|
||||
const analyzed = await session.execute("list_segments", {});
|
||||
if (!analyzed.ok) throw analyzed.error;
|
||||
const closed = await session.closeWithSnapshot(
|
||||
fixture.snapshotPath,
|
||||
@@ -471,7 +434,6 @@ describe("snapshot output cleanup lifecycle", () => {
|
||||
const committed = await readFile(fixture.snapshotPath);
|
||||
const snapshot = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!snapshot.ok) throw snapshot.error;
|
||||
if (sourceEvidence) expectSourceSnapshotRetained(snapshot.value);
|
||||
const receipt = {
|
||||
path: fixture.snapshotPath,
|
||||
bytes: committed.byteLength,
|
||||
@@ -1710,139 +1672,220 @@ it("retains a native dispatch workflow snapshot without a deep provider profile"
|
||||
);
|
||||
});
|
||||
|
||||
it("replays source-bearing direct analysis only while snapshot source Evidence is present", async () => {
|
||||
const fixture = await createSnapshotReplayFixture(
|
||||
"rea-direct-source-replay-",
|
||||
);
|
||||
const provider = fixture.createProvider({
|
||||
supportedOperations: ["procedure_pseudo_code"],
|
||||
sourceEvidence: true,
|
||||
it("runs decompile export against the provider after loading a pseudocode snapshot", async () => {
|
||||
const fixture = await createSnapshotReplayFixture("rea-directory-snapshot-");
|
||||
const original = fixture.createProvider({
|
||||
supportedOperations: ["list_procedures", "procedure_pseudo_code"],
|
||||
});
|
||||
const dependencies = fixture.dependencies(provider);
|
||||
const parameters = { procedure: "0x1000", document: "fixture" };
|
||||
const options = {
|
||||
snapshotPath: fixture.snapshotPath,
|
||||
includeEvidenceSources: true,
|
||||
const provider: AnalysisProvider = {
|
||||
...original,
|
||||
createClient: (...args) => {
|
||||
const client = original.createClient(...args);
|
||||
return {
|
||||
...client,
|
||||
execute: async (operation, parameters, options) => {
|
||||
const result = await client.execute(operation, parameters, options);
|
||||
return result.ok && operation === "procedure_pseudo_code"
|
||||
? ok({
|
||||
...result.value,
|
||||
sourceDescriptor: {
|
||||
kind: "single-function-decompile" as const,
|
||||
},
|
||||
})
|
||||
: result;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const run = () =>
|
||||
runDirectAnalysis(
|
||||
const dependencies = fixture.dependencies(provider);
|
||||
const first = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "0x1000", document: fixture.path },
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
expect(first.ok).toBe(true);
|
||||
const snapshot = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!snapshot.ok) throw snapshot.error;
|
||||
expect(
|
||||
snapshot.value.entries.some(
|
||||
(entry) => entry.operation === "procedure_pseudo_code",
|
||||
),
|
||||
).toBe(true);
|
||||
const directory = join(dirname(fixture.snapshotPath), "export");
|
||||
const exported = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
"decompile_all",
|
||||
{ output_directory: directory, layout: "address", resume: false },
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
expect(exported.ok, JSON.stringify(exported)).toBe(true);
|
||||
expect(
|
||||
fixture.calls.filter((name) => name === "procedure_pseudo_code"),
|
||||
).toHaveLength(2);
|
||||
const index = JSON.parse(
|
||||
await readFile(join(directory, "decompile-index.json"), "utf8"),
|
||||
) as { functions: Array<{ provider_source?: unknown }> };
|
||||
expect(index.functions[0]?.provider_source).toEqual({
|
||||
status: "reported",
|
||||
source: { kind: "single-function-decompile" },
|
||||
});
|
||||
});
|
||||
it.each([false, true])(
|
||||
"does not replay a fresh decompile from a selected snapshot (document supplied: %s)",
|
||||
async (withDocument) => {
|
||||
const fixture = await createSnapshotReplayFixture(
|
||||
"rea-fresh-decompile-snapshot-",
|
||||
);
|
||||
const original = fixture.createProvider({
|
||||
supportedOperations: ["procedure_pseudo_code"],
|
||||
});
|
||||
const provider: AnalysisProvider = {
|
||||
...original,
|
||||
capabilities: () =>
|
||||
original.capabilities().map((descriptor) => ({
|
||||
...descriptor,
|
||||
independentDecompile: true as const,
|
||||
})),
|
||||
};
|
||||
const args = {
|
||||
procedure: "fixture",
|
||||
analysis: "independent_fresh",
|
||||
...(withDocument ? { document: fixture.path } : {}),
|
||||
};
|
||||
const first = await runDirectAnalysis(
|
||||
fixture.dependencies(provider),
|
||||
fixture.path,
|
||||
"procedure_pseudo_code",
|
||||
args,
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
const second = await runDirectAnalysis(
|
||||
fixture.dependencies(provider),
|
||||
fixture.path,
|
||||
"procedure_pseudo_code",
|
||||
args,
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(
|
||||
fixture.calls.filter(
|
||||
(operation) => operation === "procedure_pseudo_code",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!saved.ok) throw saved.error;
|
||||
expect(
|
||||
saved.value.entries.some(
|
||||
(entry) => entry.operation === "procedure_pseudo_code",
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false])(
|
||||
"does not replay an imported fresh decompile (provider declares support: %s)",
|
||||
async (declared) => {
|
||||
const fixture = await createSnapshotReplayFixture(
|
||||
"rea-fresh-imported-snapshot-",
|
||||
);
|
||||
const original = fixture.createProvider({
|
||||
supportedOperations: ["procedure_pseudo_code"],
|
||||
});
|
||||
const provider: AnalysisProvider = {
|
||||
...original,
|
||||
capabilities: () =>
|
||||
original.capabilities().map((descriptor) => ({
|
||||
...descriptor,
|
||||
...(declared ? { independentDecompile: true as const } : {}),
|
||||
})),
|
||||
};
|
||||
const parameters = { procedure: "fixture", document: fixture.path };
|
||||
const dependencies = fixture.dependencies(provider);
|
||||
const first = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
"procedure_pseudo_code",
|
||||
parameters,
|
||||
options,
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
const firstResult = await run();
|
||||
const first = parseEvidence(projectCliAnalysisResult(firstResult));
|
||||
expect(
|
||||
firstResult.sourceEvidence?.map((record) => record.evidence_id),
|
||||
).toEqual(first.evidence_links);
|
||||
expect(first.evidence_links).toHaveLength(1);
|
||||
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!saved.ok) throw saved.error;
|
||||
expect(saved.value.entries).toHaveLength(1);
|
||||
const sourceId = first.evidence_links[0];
|
||||
expect(
|
||||
saved.value.evidence_bundle.records.some(
|
||||
(record) => record.evidence_id === sourceId,
|
||||
),
|
||||
).toBe(true);
|
||||
const initialStarts = fixture.starts.length;
|
||||
const replayed = await run();
|
||||
expect(
|
||||
parseEvidence(projectCliAnalysisResult(replayed)).evidence_links,
|
||||
).toEqual(first.evidence_links);
|
||||
expect(replayed.sourceEvidence?.map((record) => record.evidence_id)).toEqual(
|
||||
first.evidence_links,
|
||||
);
|
||||
const outputPath = join(
|
||||
dirname(fixture.snapshotPath),
|
||||
"replayed-evidence.json",
|
||||
);
|
||||
await publishCliAnalysisEvidence(replayed, {
|
||||
providerId: IDENTITY.id,
|
||||
operation: "procedure_pseudo_code",
|
||||
signal: new AbortController().signal,
|
||||
detail: "complete",
|
||||
evidenceOutput: outputPath,
|
||||
});
|
||||
const published = JSON.parse(await readFile(outputPath, "utf8")) as {
|
||||
records: Evidence[];
|
||||
};
|
||||
expect(published.records.map((record) => record.evidence_id).sort()).toEqual(
|
||||
[first.evidence_id, ...first.evidence_links].sort(),
|
||||
);
|
||||
expect(fixture.starts).toHaveLength(initialStarts);
|
||||
|
||||
const withoutSource = {
|
||||
...saved.value,
|
||||
evidence_bundle: createEvidenceBundle(
|
||||
saved.value.evidence_bundle.records.filter(
|
||||
(record) => record.evidence_id !== sourceId,
|
||||
if (!first.ok) throw first.error;
|
||||
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!saved.ok) throw saved.error;
|
||||
const entry = saved.value.entries.find(
|
||||
(item) => item.operation === "procedure_pseudo_code",
|
||||
);
|
||||
const evidence = saved.value.evidence_bundle.records.find(
|
||||
(item) => item.operation === "procedure_pseudo_code",
|
||||
);
|
||||
if (
|
||||
entry === undefined ||
|
||||
evidence === undefined ||
|
||||
evidence.analysis_profile === null
|
||||
)
|
||||
throw new Error("Missing saved decompile");
|
||||
const freshParameters = { ...parameters, analysis: "independent_fresh" };
|
||||
const historical = createEvidence(
|
||||
{
|
||||
path: fixture.path,
|
||||
sha256: saved.value.target.sha256,
|
||||
format: saved.value.target.format,
|
||||
},
|
||||
evidence.provider,
|
||||
{
|
||||
operation: evidence.operation,
|
||||
parameters: freshParameters,
|
||||
result: evidence.normalized_result,
|
||||
rawResult: evidence.raw_result,
|
||||
analysisProfile: evidence.analysis_profile,
|
||||
confidence: evidence.confidence,
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
},
|
||||
);
|
||||
const written = await withArtifactResourceScope((resources) =>
|
||||
writeAnalysisSnapshot(
|
||||
{
|
||||
...saved.value,
|
||||
entries: [
|
||||
{
|
||||
...entry,
|
||||
parameters: freshParameters,
|
||||
query_id: analysisQueryId(
|
||||
saved.value.target,
|
||||
saved.value.binding,
|
||||
entry.operation,
|
||||
freshParameters,
|
||||
),
|
||||
},
|
||||
],
|
||||
evidence_bundle: createEvidenceBundle([historical]),
|
||||
},
|
||||
fixture.snapshotPath,
|
||||
true,
|
||||
resources,
|
||||
),
|
||||
),
|
||||
};
|
||||
const written = await withArtifactResourceScope((resources) =>
|
||||
writeAnalysisSnapshot(withoutSource, fixture.snapshotPath, true, resources),
|
||||
);
|
||||
if (!written.ok) throw written.error;
|
||||
const refreshedResult = await run();
|
||||
const refreshed = parseEvidence(projectCliAnalysisResult(refreshedResult));
|
||||
expect(
|
||||
refreshedResult.sourceEvidence?.map((record) => record.evidence_id),
|
||||
).toEqual(first.evidence_links);
|
||||
expect(fixture.starts).toHaveLength(initialStarts + 1);
|
||||
expect(refreshed.evidence_links).toEqual(first.evidence_links);
|
||||
const repaired = await readAnalysisSnapshot(fixture.snapshotPath);
|
||||
if (!repaired.ok) throw repaired.error;
|
||||
expect(
|
||||
repaired.value.evidence_bundle.records.some(
|
||||
(record) => record.evidence_id === sourceId,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves captured sources when a transitive source is missing", async () => {
|
||||
const fixture = await createSnapshotReplayFixture(
|
||||
"rea-direct-partial-source-",
|
||||
);
|
||||
const provider = fixture.createProvider({
|
||||
supportedOperations: ["procedure_pseudo_code"],
|
||||
sourceEvidence: true,
|
||||
sourceMissingLink: true,
|
||||
});
|
||||
const result = await runDirectAnalysis(
|
||||
fixture.dependencies(provider),
|
||||
fixture.path,
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "0x1000", document: "fixture" },
|
||||
{ includeEvidenceSources: true },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.sourceEvidence).toHaveLength(1);
|
||||
const outputPath = join(dirname(fixture.snapshotPath), "missing-source.json");
|
||||
const output = await publishCliAnalysisEvidence(result, {
|
||||
providerId: IDENTITY.id,
|
||||
operation: "procedure_pseudo_code",
|
||||
signal: new AbortController().signal,
|
||||
detail: "complete",
|
||||
evidenceOutput: outputPath,
|
||||
});
|
||||
const failure = output as {
|
||||
details: {
|
||||
source_closure: {
|
||||
records: Evidence[];
|
||||
omitted: { evidence_id: string }[];
|
||||
complete: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(failure.details.source_closure.complete).toBe(false);
|
||||
expect(
|
||||
failure.details.source_closure.records.map((record) => record.evidence_id),
|
||||
).toEqual(result.sourceEvidence?.map((record) => record.evidence_id));
|
||||
expect(failure.details.source_closure.omitted).toEqual([
|
||||
{ evidence_id: `ev_${"0".repeat(64)}`, reason: "not_captured" },
|
||||
]);
|
||||
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
);
|
||||
if (!written.ok) throw written.error;
|
||||
const fresh = await runDirectAnalysis(
|
||||
dependencies,
|
||||
fixture.path,
|
||||
"procedure_pseudo_code",
|
||||
freshParameters,
|
||||
{ snapshotPath: fixture.snapshotPath },
|
||||
);
|
||||
expect(fresh.ok).toBe(declared);
|
||||
if (!fresh.ok)
|
||||
expect(fresh.error, JSON.stringify(fresh.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
});
|
||||
expect(
|
||||
fixture.calls.filter(
|
||||
(operation) => operation === "procedure_pseudo_code",
|
||||
),
|
||||
).toHaveLength(declared ? 2 : 1);
|
||||
},
|
||||
);
|
||||
@@ -572,63 +572,6 @@ it.each(["interrupt", "cleanup-failure", "interrupt-cleanup-failure"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["interrupt", "cleanup-failure"] as const)(
|
||||
"retains captured source closure when direct finalization fails: %s",
|
||||
async (scenario) => {
|
||||
const workflow = await composedWorkflowFixture();
|
||||
const controller = new AbortController();
|
||||
const close = workflow.session.close.bind(workflow.session);
|
||||
workflow.session.close = async () => {
|
||||
workflow.session.close = close;
|
||||
if (scenario === "interrupt") controller.abort();
|
||||
const closed = await close();
|
||||
if (scenario === "interrupt" || !closed.ok) return closed;
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
"fixture",
|
||||
["owned-root"],
|
||||
{ reason: "controlled session close failure" },
|
||||
{ operation: "close_binary" },
|
||||
),
|
||||
);
|
||||
};
|
||||
const root = await createTestTempDirectory("rea-closure-direct-captured-");
|
||||
const complete = await runDirectAnalysis(
|
||||
workflow.dependencies,
|
||||
workflow.path,
|
||||
"binary_overview",
|
||||
{},
|
||||
{ signal: controller.signal, includeEvidenceSources: true },
|
||||
);
|
||||
const output = await publishCliAnalysisEvidence(complete, {
|
||||
...delivery(root),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const failure = output as {
|
||||
code: string;
|
||||
details: { partial_observation: unknown };
|
||||
};
|
||||
expect(failure.code).toBe(
|
||||
scenario === "interrupt" ? "cancelled" : "cleanup_incomplete",
|
||||
);
|
||||
const links = parseEvidence(
|
||||
failure.details.partial_observation,
|
||||
).evidence_links;
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
expect(closureOf(output)).toMatchObject({
|
||||
publication: "not_published",
|
||||
complete: true,
|
||||
omitted_count: 0,
|
||||
});
|
||||
expect(
|
||||
closureOf(output)
|
||||
.records.map((record) => record.evidence_id)
|
||||
.sort(),
|
||||
).toEqual([...links].sort());
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("reports through the shared one-shot delivery when cancellation arrives during close", async () => {
|
||||
const root = await createTestTempDirectory("rea-closure-deliver-");
|
||||
const held = source("held");
|
||||
|
||||
@@ -7,8 +7,7 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => {
|
||||
const schema = {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://fixture.test/result",
|
||||
$defs: { value: { type: "string" as "string" | "integer" } },
|
||||
$ref: "#/$defs/value",
|
||||
type: "string" as "string" | "integer",
|
||||
};
|
||||
const first = validation.getValidator(schema);
|
||||
expect(validation.getValidator(structuredClone(schema))).toBe(first);
|
||||
@@ -17,7 +16,7 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => {
|
||||
expect(validation.assertions.validate(schema, "recorded")).toBe(true);
|
||||
expect(validation.assertions.validate(schema, 42)).toBe(false);
|
||||
|
||||
schema.$defs.value.type = "integer";
|
||||
schema.type = "integer";
|
||||
const changed = validation.getValidator(schema);
|
||||
expect(changed).not.toBe(first);
|
||||
expect(changed(42).valid).toBe(true);
|
||||
@@ -32,17 +31,13 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://json-schema.org/draft/2020-12/schema",
|
||||
"https://json-schema.org/draft/2019-09/schema",
|
||||
"http://json-schema.org/draft-07/schema#",
|
||||
])("preserves SDK format validation and references for %s", (dialect) => {
|
||||
it("preserves SDK format validation and declared dialect dispatch", () => {
|
||||
const validation = createMcpSchemaValidation();
|
||||
try {
|
||||
const schema = {
|
||||
$schema: dialect,
|
||||
$defs: { address: { type: "string", format: "email" } },
|
||||
$ref: "#/$defs/address",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "string",
|
||||
format: "email",
|
||||
} as const;
|
||||
const validator = validation.getValidator(schema);
|
||||
expect(validator("[email protected]").valid).toBe(true);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const traceFixture = () => ({
|
||||
query: "goal",
|
||||
search_mode: "literal" as const,
|
||||
matches: [{ type: "procedure" as const, address: "0x1000", value: "goal" }],
|
||||
references: ["0x1010", "0x1020", "0x1010"].map((source_address) => ({
|
||||
target_address: "0x1000",
|
||||
source_address,
|
||||
containing_procedure: {
|
||||
query_address: source_address,
|
||||
status: "unavailable" as const,
|
||||
provider_id: "kuna",
|
||||
reason: "unsupported",
|
||||
},
|
||||
})),
|
||||
truncated: true,
|
||||
residual_unknowns: ["0x1010 owner unknown", "0x1020 owner unknown"],
|
||||
});
|
||||
|
||||
it.each(["full", "compact"] as const)(
|
||||
"pages retained native trace rows and preserves source coverage across %s offline handoff",
|
||||
async (profile) => {
|
||||
const result = traceFixture();
|
||||
const parent = createEvidence(
|
||||
undefined,
|
||||
{ id: "rea-workflow", name: "REA workflow", version: "1" },
|
||||
{
|
||||
operation: "trace_feature",
|
||||
parameters: { query: "goal", case_sensitive: false },
|
||||
result,
|
||||
confidence: "derived",
|
||||
limitations: ["fixture provider coverage"],
|
||||
},
|
||||
);
|
||||
const setup = async () => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("View must not open a provider");
|
||||
});
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
clientIdentity: { name: "native-feature-view", version: "1" },
|
||||
serverOptions: {
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { ...harness, session };
|
||||
};
|
||||
const first = await setup();
|
||||
const call = async (
|
||||
client: typeof first.client,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) => {
|
||||
const reply = await client.callTool({ name, arguments: args });
|
||||
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
|
||||
return reply.structuredContent;
|
||||
};
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
};
|
||||
const view = async (
|
||||
client: typeof first.client,
|
||||
collection: "matches" | "references" | "unknowns",
|
||||
offset: number,
|
||||
limit: number,
|
||||
) => {
|
||||
const response = parseEvidence(
|
||||
await call(client, "inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "native-feature-trace", collection, offset, limit },
|
||||
}),
|
||||
);
|
||||
return analysisViewResultSchema.parse(response.normalized_result);
|
||||
};
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-native-feature-trace-view-",
|
||||
);
|
||||
const path = join(root, "evidence.json");
|
||||
try {
|
||||
expect(first.session.recordEvidence(parent).ok).toBe(true);
|
||||
const summary = analysisViewResultSchema.parse(
|
||||
parseEvidence(
|
||||
await call(first.client, "inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "summary" },
|
||||
}),
|
||||
).normalized_result,
|
||||
);
|
||||
expect(summary).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
summary: { matches: 1, references: 3, unknowns: 2, truncated: true },
|
||||
});
|
||||
const firstPage = await view(first.client, "references", 0, 2);
|
||||
const lastPage = await view(first.client, "references", 2, 2);
|
||||
expect(
|
||||
analysisViewResultSchema.safeParse({
|
||||
...firstPage,
|
||||
items: { collection: "unknowns", rows: [] },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(firstPage).toMatchObject({
|
||||
coverage: { total: 3, next_offset: 2, exhausted: false },
|
||||
items: {
|
||||
rows: [
|
||||
{ source_index: 0, value: result.references[0] },
|
||||
{ source_index: 1, value: result.references[1] },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(lastPage).toMatchObject({
|
||||
coverage: { total: 3, next_offset: null, exhausted: true },
|
||||
items: { rows: [{ source_index: 2, value: result.references[2] }] },
|
||||
});
|
||||
const filtered = analysisViewResultSchema.parse(
|
||||
parseEvidence(
|
||||
await call(first.client, "inspect_analysis_view", {
|
||||
source,
|
||||
view: {
|
||||
kind: "native-feature-trace",
|
||||
collection: "references",
|
||||
source_address: "0x1010",
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
},
|
||||
}),
|
||||
).normalized_result,
|
||||
);
|
||||
expect(filtered).toMatchObject({
|
||||
coverage: { total: 2, next_offset: null, exhausted: true },
|
||||
items: { rows: [{ source_index: 2, value: result.references[2] }] },
|
||||
});
|
||||
expect(await view(first.client, "unknowns", 1, 5)).toMatchObject({
|
||||
items: {
|
||||
rows: [{ source_index: 1, value: result.residual_unknowns[1] }],
|
||||
},
|
||||
});
|
||||
expect(await view(first.client, "matches", 9, 1)).toMatchObject({
|
||||
coverage: { status: "empty", total: 1, next_offset: null },
|
||||
});
|
||||
await call(first.client, "export_evidence_bundle", { path });
|
||||
} finally {
|
||||
await first.close();
|
||||
}
|
||||
const second = await setup();
|
||||
try {
|
||||
await call(second.client, "import_evidence_bundle", { path });
|
||||
expect(await view(second.client, "references", 2, 1)).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
items: { rows: [{ source_index: 2, value: result.references[2] }] },
|
||||
});
|
||||
} finally {
|
||||
await second.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterAll, expect, it, onTestFinished } from "vitest";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js";
|
||||
import { recordingWebRuntimePort } from "../../../src/application/WebRuntimeService.fixture.js";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
@@ -18,9 +18,6 @@ import {
|
||||
startRuntimeBrowser,
|
||||
} from "../../fixtures/webRuntime.js";
|
||||
|
||||
const schemaValidation = createMcpSchemaValidation();
|
||||
afterAll(() => schemaValidation.clear());
|
||||
|
||||
it("advertises actual SDK schemas, instrumentation effects and session-owned inline runtime evidence", async () => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Binary provider must not start");
|
||||
@@ -31,10 +28,7 @@ it("advertises actual SDK schemas, instrumentation effects and session-owned inl
|
||||
webRuntime: new WebRuntimeService(recordingWebRuntimePort()),
|
||||
},
|
||||
);
|
||||
const client = new Client(
|
||||
{ name: "web-runtime-contract", version: "1" },
|
||||
{ jsonSchemaValidator: schemaValidation },
|
||||
);
|
||||
const client = new Client({ name: "web-runtime-contract", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
@@ -45,7 +39,7 @@ it("advertises actual SDK schemas, instrumentation effects and session-owned inl
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const tools = (await client.listTools()).tools;
|
||||
const ajv = schemaValidation.assertions;
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const input = {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "allowed-page",
|
||||
@@ -124,10 +118,7 @@ cliTest(
|
||||
{ kind: "session", session },
|
||||
{ webRuntime: new WebRuntimeService(provider) },
|
||||
);
|
||||
const client = new Client(
|
||||
{ name: "runtime-frame-identity", version: "1" },
|
||||
{ jsonSchemaValidator: schemaValidation },
|
||||
);
|
||||
const client = new Client({ name: "runtime-frame-identity", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
@@ -202,10 +193,10 @@ cliTest(
|
||||
{ kind: "session", session },
|
||||
{ webRuntime: new WebRuntimeService(provider) },
|
||||
);
|
||||
const client = new Client(
|
||||
{ name: "runtime-negative-columns", version: "1" },
|
||||
{ jsonSchemaValidator: schemaValidation },
|
||||
);
|
||||
const client = new Client({
|
||||
name: "runtime-negative-columns",
|
||||
version: "1",
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
@@ -233,7 +224,7 @@ cliTest(
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const ajv = schemaValidation.assertions;
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
@@ -302,10 +293,7 @@ it.each([
|
||||
{ kind: "session", session },
|
||||
{ webRuntime: new WebRuntimeService(new CdpWebRuntimeProvider()) },
|
||||
);
|
||||
const client = new Client(
|
||||
{ name: "web-runtime-partial", version: "1" },
|
||||
{ jsonSchemaValidator: schemaValidation },
|
||||
);
|
||||
const client = new Client({ name: "web-runtime-partial", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
@@ -22,13 +22,9 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
evidenceBundleSchema,
|
||||
} from "../../../src/domain/evidenceBundle.js";
|
||||
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
@@ -581,188 +577,6 @@ describe("MCP investigation history snapshot replay", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("replays a source-bearing observation through an exported MCP snapshot", async () => {
|
||||
const { targetPaths, snapshotPath } = await createWorkflowFiles(
|
||||
"rea-mcp-source-replay-",
|
||||
["fixture.hop"],
|
||||
);
|
||||
const targetPath = targetPaths[0];
|
||||
if (targetPath === undefined) throw new Error("Expected a fixture target");
|
||||
const starts: string[] = [];
|
||||
const calls: string[] = [];
|
||||
const base = makeProvider(starts, calls);
|
||||
const provider: AnalysisProvider = {
|
||||
...base,
|
||||
createClient: (target, selectedProfile, context) => {
|
||||
const client = base.createClient(target, selectedProfile, context);
|
||||
return {
|
||||
...client,
|
||||
execute: async (...args) => {
|
||||
const result = await client.execute(...args);
|
||||
if (!result.ok || args[0] !== "list_strings") return result;
|
||||
return ok({
|
||||
...result.value,
|
||||
sourceEvidence: [
|
||||
createEvidence(target, identity, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: "historical source",
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const { mcp } = await connectWorkflowMcp(
|
||||
createWorkflowSession(provider),
|
||||
"source-replay",
|
||||
);
|
||||
await openWorkflowTarget(mcp, targetPath);
|
||||
const read = (client: Client) =>
|
||||
client.callTool({
|
||||
name: "list_strings",
|
||||
arguments: { document: "fixture" },
|
||||
});
|
||||
const first = await read(mcp);
|
||||
const firstEvidence = toolContract("list_strings").outputSchema.parse(
|
||||
first.structuredContent,
|
||||
);
|
||||
expect(firstEvidence.evidence_links).toHaveLength(1);
|
||||
const second = await read(mcp);
|
||||
const secondEvidence = toolContract("list_strings").outputSchema.parse(
|
||||
second.structuredContent,
|
||||
);
|
||||
expect(secondEvidence.evidence_links).toEqual(firstEvidence.evidence_links);
|
||||
expect(secondEvidence.limitations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("did not re-run the provider"),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
calls.filter((operation) => operation === "list_strings"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: snapshotPath },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const { mcp: reopened } = await connectWorkflowMcp(
|
||||
createWorkflowSession(provider),
|
||||
"source-replay-import",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await reopened.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: targetPath, snapshot_path: snapshotPath },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const replayed = toolContract("list_strings").outputSchema.parse(
|
||||
(await read(reopened)).structuredContent,
|
||||
);
|
||||
expect(replayed.evidence_links).toEqual(firstEvidence.evidence_links);
|
||||
expect(replayed.limitations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("did not re-run the provider"),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
calls.filter((operation) => operation === "list_strings"),
|
||||
).toHaveLength(1);
|
||||
const bundle = await reopened.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {
|
||||
detail: "records",
|
||||
filters: { evidence_id: firstEvidence.evidence_links[0] },
|
||||
},
|
||||
});
|
||||
expect(bundle.isError).not.toBe(true);
|
||||
expect(
|
||||
toolContract("get_evidence_bundle").outputSchema.parse(
|
||||
bundle.structuredContent,
|
||||
).result,
|
||||
).toMatchObject({
|
||||
records: [
|
||||
{
|
||||
evidence_id: firstEvidence.evidence_links[0],
|
||||
normalized_result: "historical source",
|
||||
},
|
||||
],
|
||||
});
|
||||
await expectRestoredSourceReplay(provider, targetPath, snapshotPath, calls);
|
||||
});
|
||||
|
||||
const expectRestoredSourceReplay = async (
|
||||
provider: AnalysisProvider,
|
||||
targetPath: string,
|
||||
snapshotPath: string,
|
||||
calls: string[],
|
||||
) => {
|
||||
const saved = await readAnalysisSnapshot(snapshotPath);
|
||||
if (!saved.ok) throw saved.error;
|
||||
const source = saved.value.evidence_bundle.records.find(
|
||||
(record) => record.operation === "health",
|
||||
);
|
||||
if (source === undefined) throw new Error("Expected historical source");
|
||||
const directory = dirname(snapshotPath);
|
||||
const prunedPath = join(directory, "missing-source.json");
|
||||
const sourcePath = join(directory, "source-bundle.json");
|
||||
await writeFile(
|
||||
prunedPath,
|
||||
JSON.stringify({
|
||||
...saved.value,
|
||||
evidence_bundle: createEvidenceBundle(
|
||||
saved.value.evidence_bundle.records.filter(
|
||||
(record) => record.evidence_id !== source.evidence_id,
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
await writeFile(sourcePath, JSON.stringify(createEvidenceBundle([source])));
|
||||
const { mcp } = await connectWorkflowMcp(
|
||||
createWorkflowSession(provider),
|
||||
"source-restored-after-open",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: targetPath, snapshot_path: prunedPath },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "import_evidence_bundle",
|
||||
arguments: { path: sourcePath },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const replayed = toolContract("list_strings").outputSchema.parse(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "list_strings",
|
||||
arguments: { document: "fixture" },
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
expect(replayed.evidence_links).toEqual([source.evidence_id]);
|
||||
expect(replayed.limitations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("did not re-run the provider"),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
calls.filter((operation) => operation === "list_strings"),
|
||||
).toHaveLength(1);
|
||||
};
|
||||
|
||||
const createWorkflowFiles = async (
|
||||
prefix: string,
|
||||
filenames: readonly string[],
|
||||
|
||||
@@ -1317,29 +1317,6 @@ it.skipIf(!onDarwin)(
|
||||
missingAppleBoundaryFailsClosed: true,
|
||||
malformedBuffersRejected: true,
|
||||
invalidPointerSizeFailsClosed: true,
|
||||
incompleteRestrictedArguments: [
|
||||
"malformed_procargs_path_padding",
|
||||
"malformed_procargs_arguments",
|
||||
"apple_vector_unavailable",
|
||||
].map((reason) => [
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "platform_binary_environment_withheld",
|
||||
},
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "restricted_process_environment_withheld",
|
||||
},
|
||||
{ state: "unavailable", reason },
|
||||
]),
|
||||
signedReadableTokens: [true, true],
|
||||
signedInvalidTokens: [
|
||||
"duplicate_run_token",
|
||||
"invalid_run_token_encoding",
|
||||
].map((reason) => [
|
||||
{ state: "unavailable", reason },
|
||||
{ state: "unavailable", reason },
|
||||
]),
|
||||
malformedStages: [
|
||||
{ state: "unavailable", reason: "malformed_procargs_header" },
|
||||
{ state: "unavailable", reason: "malformed_procargs_argument_count" },
|
||||
|
||||
@@ -93,7 +93,7 @@ test("keeps each Kuna no-pseudocode observation's limitations on its batch item"
|
||||
// profile commits only analysis inputs. Composed and retained views must
|
||||
// preserve the same warning for agents reading those results.
|
||||
const verificationWarning =
|
||||
"Kuna 1.833 is not REA's verified 1.990 release";
|
||||
"Kuna 1.833 is not REA's verified 1.989 release";
|
||||
expect(batch.limitations).toContainEqual(
|
||||
expect.stringContaining(verificationWarning),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { appendFile, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it as test } from "vitest";
|
||||
import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js";
|
||||
import { decompileDirectoryIndexSchema } from "../../../../src/contracts/decompileDirectory.js";
|
||||
import { parseEvidence } from "../../../../src/domain/evidence.js";
|
||||
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
|
||||
import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js";
|
||||
import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { setFakeKunaBehavior, writeFakeKuna } from "./fakeKuna.js";
|
||||
import { FUNCTIONS } from "./kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("retains each written function's producer across an export resume", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-directory-provenance-");
|
||||
const kuna = await writeFakeKuna(root, { functions: FUNCTIONS });
|
||||
const target = join(root, "program");
|
||||
const output = join(root, "output");
|
||||
await writeFile(target, elfNoteFixture({ bits: 64, type: 2 }));
|
||||
const run = async (cache: string, resume: boolean) => {
|
||||
const provider = new KunaProvider(
|
||||
{ REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, cache) },
|
||||
{ platform: process.platform },
|
||||
);
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([provider], "kuna"),
|
||||
);
|
||||
const harness = await createApplicationMcpHarness({ session });
|
||||
try {
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
const response = await harness.client.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
return parseEvidence(response.structuredContent);
|
||||
};
|
||||
const opened = await harness.client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, provider_id: "kuna" },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
await call("procedure_assembly", { procedure: "main" });
|
||||
const result = await call("decompile_all", {
|
||||
output_directory: output,
|
||||
resume,
|
||||
});
|
||||
const resultValue = result.normalized_result as { index_path: string };
|
||||
return decompileDirectoryIndexSchema.parse(
|
||||
JSON.parse(await readFile(resultValue.index_path, "utf8")),
|
||||
);
|
||||
} finally {
|
||||
await harness.close();
|
||||
const closed = await provider.closeOwnedResources();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
};
|
||||
const partial = await run("partial", false);
|
||||
const first = partial.functions.find((item) => item.address === "0x1000");
|
||||
const failed = partial.functions.find((item) => item.address === "0x1080");
|
||||
expect(first?.status).toBe("written");
|
||||
expect(first?.provider_source).toMatchObject({
|
||||
status: "reported",
|
||||
source: { kind: "file-range" },
|
||||
});
|
||||
expect(failed?.status).toBe("failed");
|
||||
expect(failed?.provider_source).toMatchObject({
|
||||
status: "reported",
|
||||
source: { kind: "file-range" },
|
||||
});
|
||||
if (first?.relative_path === undefined)
|
||||
throw new Error("missing initial file");
|
||||
const digest = (value: Uint8Array) =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
const before = digest(await readFile(join(output, first.relative_path)));
|
||||
// Simulate an interrupted append after the committed failed observation.
|
||||
await appendFile(join(output, "decompile-journal.jsonl"), '{"kind":"func');
|
||||
await setFakeKunaBehavior(kuna, {
|
||||
functions: FUNCTIONS.map(({ name, addr }) => ({ name, addr })),
|
||||
});
|
||||
const clean = await run("clean", true);
|
||||
const kept = clean.functions.find((item) => item.address === "0x1000");
|
||||
const recovered = clean.functions.find((item) => item.address === "0x1080");
|
||||
expect(kept).toEqual(first);
|
||||
expect(digest(await readFile(join(output, first.relative_path)))).toBe(
|
||||
before,
|
||||
);
|
||||
expect(recovered?.status).toBe("written");
|
||||
expect(recovered?.provider_source).toMatchObject({
|
||||
status: "reported",
|
||||
source: { kind: "file-range" },
|
||||
});
|
||||
if (recovered === undefined) throw new Error("missing recovered function");
|
||||
const oldSource = first.provider_source;
|
||||
const newSource = recovered.provider_source;
|
||||
if (
|
||||
oldSource?.status !== "reported" ||
|
||||
oldSource.source.kind !== "file-range" ||
|
||||
newSource?.status !== "reported" ||
|
||||
newSource.source.kind !== "file-range"
|
||||
)
|
||||
throw new Error("missing source file ranges");
|
||||
expect(newSource.source.sha256).not.toBe(oldSource.source.sha256);
|
||||
const journal = (
|
||||
await readFile(join(output, "decompile-journal.jsonl"), "utf8")
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as {
|
||||
address?: string;
|
||||
status?: string;
|
||||
provider_source?: unknown;
|
||||
},
|
||||
);
|
||||
expect(journal).toContainEqual(
|
||||
expect.objectContaining({
|
||||
address: "0x1080",
|
||||
status: "failed",
|
||||
provider_source: failed?.provider_source,
|
||||
}),
|
||||
);
|
||||
// The descriptor is historical: the source cache can disappear while the
|
||||
// verified output and its recorded source facts remain unchanged.
|
||||
await unlink(oldSource.source.path);
|
||||
const afterSourceRemoval = await run("clean", true);
|
||||
expect(
|
||||
afterSourceRemoval.functions.find((item) => item.address === "0x1000"),
|
||||
).toEqual(first);
|
||||
expect(digest(await readFile(join(output, first.relative_path)))).toBe(
|
||||
before,
|
||||
);
|
||||
const outputPath = join(output, first.relative_path);
|
||||
const originalBytes = await readFile(outputPath);
|
||||
await writeFile(outputPath, "owned modified output");
|
||||
const conflict = await run("clean", true);
|
||||
const conflictMain = conflict.functions.find(
|
||||
(item) => item.address === "0x1000",
|
||||
);
|
||||
expect(conflictMain).toMatchObject({
|
||||
status: "failed",
|
||||
failure: { code: "output_exists" },
|
||||
});
|
||||
expect(conflictMain?.provider_source).toBeUndefined();
|
||||
expect(await readFile(outputPath, "utf8")).toBe("owned modified output");
|
||||
await writeFile(outputPath, originalBytes);
|
||||
const restored = await run("clean", true);
|
||||
expect(restored.functions.find((item) => item.address === "0x1000")).toEqual(
|
||||
first,
|
||||
);
|
||||
// A file published before a journal append can be adopted on resume, but
|
||||
// matching bytes do not identify the run that originally wrote the file.
|
||||
const journalPath = join(output, "decompile-journal.jsonl");
|
||||
const header = (await readFile(journalPath, "utf8")).split("\n")[0];
|
||||
await writeFile(journalPath, `${header}\n`);
|
||||
const adopted = await run("clean", true);
|
||||
const adoptedMain = adopted.functions.find(
|
||||
(item) => item.address === "0x1000",
|
||||
);
|
||||
expect(adoptedMain?.provider_source).toEqual({
|
||||
status: "unknown",
|
||||
reason: "existing_file_origin_unknown",
|
||||
});
|
||||
expect(digest(await readFile(join(output, first.relative_path)))).toBe(
|
||||
before,
|
||||
);
|
||||
});
|
||||
@@ -34,7 +34,7 @@ it("reports an unconfigured Kuna as a missing optional engine", async () => {
|
||||
it("publishes the caller's explicit settings once the installation validates", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-doctor-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
version: { stdout: "kuna 1.990\n" },
|
||||
version: { stdout: "kuna 1.989\n" },
|
||||
});
|
||||
const cache = join(root, "cache");
|
||||
|
||||
@@ -48,7 +48,7 @@ it("publishes the caller's explicit settings once the installation validates", a
|
||||
expect(inspection).toMatchObject({
|
||||
configured: true,
|
||||
available: true,
|
||||
providerVersion: "1.990",
|
||||
providerVersion: "1.989",
|
||||
registrationEnvironment: {
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: cache,
|
||||
@@ -59,10 +59,10 @@ it("publishes the caller's explicit settings once the installation validates", a
|
||||
name: "installation",
|
||||
ok: true,
|
||||
detail: expect.stringMatching(
|
||||
/reports Kuna 1\.990 \(sha256 [a-f0-9]{64}\)\.$/u,
|
||||
/reports Kuna 1\.989 \(sha256 [a-f0-9]{64}\)\.$/u,
|
||||
),
|
||||
remediation: null,
|
||||
details: { version: "1.990", verified_version: "1.990" },
|
||||
details: { version: "1.989", verified_version: "1.989" },
|
||||
},
|
||||
{
|
||||
name: "cache",
|
||||
@@ -88,7 +88,7 @@ it("flags an unverified release without failing the installation", async () => {
|
||||
"You may continue with the selected unverified release.",
|
||||
),
|
||||
remediation: expect.stringContaining("setting REA_KUNA_COMMAND"),
|
||||
details: { verified_version: "1.990" },
|
||||
details: { verified_version: "1.989" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, expect, it as test, vi } from "vitest";
|
||||
import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js";
|
||||
import { parseEvidence } from "../../../../src/domain/evidence.js";
|
||||
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
|
||||
import { KunaRunSlots } from "../../../../src/kuna/KunaRunSlots.js";
|
||||
import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js";
|
||||
import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js";
|
||||
import { advertisedMcpToolSchemas } from "../../../fixtures/advertisedMcpTool.js";
|
||||
import { createMcpSchemaValidation } from "../../../fixtures/mcpSchemaValidation.js";
|
||||
import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
fakeKunaCalls,
|
||||
setFakeKunaBehavior,
|
||||
writeFakeKuna,
|
||||
} from "./fakeKuna.js";
|
||||
import { FUNCTIONS, unprovenCleanupLauncher } from "./kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
const schemas = createMcpSchemaValidation();
|
||||
afterAll(() => schemas.clear());
|
||||
|
||||
it("runs a cold independent request without starting a project export", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-independent-cold-");
|
||||
const kuna = await writeFakeKuna(root, { functions: FUNCTIONS });
|
||||
const target = join(root, "program");
|
||||
await writeFile(target, elfNoteFixture({ bits: 64, type: 2 }));
|
||||
const provider = new KunaProvider(
|
||||
{
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
REA_KUNA_TIMEOUT_MS: "300",
|
||||
},
|
||||
{ platform: process.platform },
|
||||
);
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([provider], "kuna"),
|
||||
);
|
||||
const harness = await createApplicationMcpHarness({ session });
|
||||
try {
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
const result = await harness.client.callTool({ name, arguments: args });
|
||||
expect(result.isError, JSON.stringify(result)).not.toBe(true);
|
||||
return result.structuredContent;
|
||||
};
|
||||
await call("open_binary", { path: target, provider_id: "kuna" });
|
||||
const evidence = parseEvidence(
|
||||
await call("procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
analysis: "independent_fresh",
|
||||
}),
|
||||
);
|
||||
expect(evidence.raw_result).toMatchObject({
|
||||
source: { kind: "single-function" },
|
||||
project_export: { state: "not-started" },
|
||||
});
|
||||
const calls = await fakeKunaCalls(kuna);
|
||||
expect(calls.filter(({ argv }) => argv[0] === "functions")).toHaveLength(1);
|
||||
expect(calls.filter(({ argv }) => argv[0] === "decompile")).toHaveLength(1);
|
||||
expect(
|
||||
calls.filter(({ argv }) => argv[0] === "decompile-project"),
|
||||
).toHaveLength(0);
|
||||
await setFakeKunaBehavior(kuna, {
|
||||
functions: FUNCTIONS,
|
||||
decompile: "hang",
|
||||
});
|
||||
const timeout = await harness.client.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: "main", analysis: "independent_fresh" },
|
||||
});
|
||||
expect(timeout.isError).toBe(true);
|
||||
const failure = JSON.parse(
|
||||
(timeout.content[0] as { text: string }).text,
|
||||
) as {
|
||||
error: { code: string; remediation: { action: string } };
|
||||
};
|
||||
expect(failure.error).toMatchObject({
|
||||
code: "provider_timeout",
|
||||
remediation: {
|
||||
action:
|
||||
"Raise REA_KUNA_TIMEOUT_MS in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.",
|
||||
},
|
||||
details: { timeout_ms: 300 },
|
||||
});
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile-project",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
await setFakeKunaBehavior(kuna, { functions: FUNCTIONS });
|
||||
await call("procedure_assembly", { procedure: "main" });
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile-project",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
await harness.close();
|
||||
const closed = await provider.closeOwnedResources();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["compact", "full"] as const)(
|
||||
"keeps the failed project observation when an explicit fresh retry succeeds (%s)",
|
||||
async (profile) => {
|
||||
const root = await createTestTempDirectory("rea-kuna-independent-");
|
||||
const kuna = await writeFakeKuna(root, { functions: FUNCTIONS });
|
||||
const target = join(root, "program");
|
||||
await writeFile(target, elfNoteFixture({ bits: 64, type: 2 }));
|
||||
const provider = new KunaProvider(
|
||||
{ REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, "cache") },
|
||||
{ platform: process.platform },
|
||||
);
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([provider], "kuna"),
|
||||
);
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
schemaValidator: schemas,
|
||||
serverOptions: {
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
const schema = await advertisedMcpToolSchemas(
|
||||
harness.client,
|
||||
name,
|
||||
schemas.assertions,
|
||||
);
|
||||
const result = await harness.client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ toolDefinition: schema.toolDefinition },
|
||||
);
|
||||
expect(result.isError, JSON.stringify(result)).not.toBe(true);
|
||||
expect(
|
||||
schema.ajv.validate(schema.outputSchema, result.structuredContent),
|
||||
JSON.stringify(schema.ajv.errors),
|
||||
).toBe(true);
|
||||
return result.structuredContent;
|
||||
};
|
||||
await call("open_binary", { path: target, provider_id: "kuna" });
|
||||
await call("procedure_assembly", { procedure: "broken" });
|
||||
const original = parseEvidence(
|
||||
await call("procedure_pseudo_code", { procedure: "broken" }),
|
||||
);
|
||||
expect(original.normalized_result).toBeNull();
|
||||
expect(original.limitations).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("independent_fresh")]),
|
||||
);
|
||||
const status = (await call("binary_session", { detail: "summary" })) as {
|
||||
result: {
|
||||
capabilities: Array<{
|
||||
groups: Array<{
|
||||
operations: string[];
|
||||
independent_decompile?: boolean;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(
|
||||
status.result.capabilities
|
||||
.flatMap((set) => set.groups)
|
||||
.find((group) => group.operations.includes("procedure_pseudo_code")),
|
||||
).toMatchObject({ independent_decompile: true });
|
||||
const first = parseEvidence(
|
||||
await call("procedure_pseudo_code", {
|
||||
procedure: "broken",
|
||||
analysis: "independent_fresh",
|
||||
}),
|
||||
);
|
||||
const second = parseEvidence(
|
||||
await call("procedure_pseudo_code", {
|
||||
procedure: "broken",
|
||||
analysis: "independent_fresh",
|
||||
}),
|
||||
);
|
||||
expect(first.normalized_result).toEqual(
|
||||
expect.stringContaining("single-function"),
|
||||
);
|
||||
expect(first.parameters.analysis).toBe("independent_fresh");
|
||||
expect(first.evidence_id).not.toBe(original.evidence_id);
|
||||
expect(second.evidence_id).toBe(first.evidence_id);
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
const after = parseEvidence(
|
||||
await call("procedure_pseudo_code", { procedure: "broken" }),
|
||||
);
|
||||
expect(after.normalized_result).toBeNull();
|
||||
const bundle = (await call("get_evidence_bundle", {})) as {
|
||||
result: { records: Array<{ evidence_id: string }> };
|
||||
};
|
||||
expect(bundle.result.records.map((record) => record.evidence_id)).toEqual(
|
||||
expect.arrayContaining([original.evidence_id, first.evidence_id]),
|
||||
);
|
||||
} finally {
|
||||
await harness.close();
|
||||
const closed = await provider.closeOwnedResources();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains the independent run slot until an unproven stop is resolved", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-independent-cleanup-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
functions: FUNCTIONS,
|
||||
decompile: "hang",
|
||||
});
|
||||
const target = join(root, "program");
|
||||
await writeFile(target, elfNoteFixture({ bits: 64, type: 2 }));
|
||||
const unproven = unprovenCleanupLauncher("decompile");
|
||||
const provider = new KunaProvider(
|
||||
{
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_CACHE_DIR: join(root, "cache"),
|
||||
REA_KUNA_TIMEOUT_MS: "300",
|
||||
},
|
||||
{
|
||||
platform: process.platform,
|
||||
launcher: unproven.launcher,
|
||||
singleFunctionRuns: 1,
|
||||
},
|
||||
);
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry([provider], "kuna"),
|
||||
);
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
schemaValidator: schemas,
|
||||
});
|
||||
const caller = new AbortController();
|
||||
let restoreAcquire: (() => void) | undefined;
|
||||
let queued: Promise<unknown> | undefined;
|
||||
try {
|
||||
const call = (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
harness.client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ ...(signal === undefined ? {} : { signal }), timeout: 5000 },
|
||||
);
|
||||
expect(
|
||||
(await call("open_binary", { path: target, provider_id: "kuna" }))
|
||||
.isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
(await call("procedure_assembly", { procedure: "broken" })).isError,
|
||||
).not.toBe(true);
|
||||
const first = await call("procedure_pseudo_code", {
|
||||
procedure: "broken",
|
||||
analysis: "independent_fresh",
|
||||
});
|
||||
expect(first.isError).toBe(true);
|
||||
const failure = JSON.parse((first.content[0] as { text: string }).text) as {
|
||||
error: { code: string };
|
||||
};
|
||||
expect(failure.error.code).toBe("cleanup_incomplete");
|
||||
const acquire = KunaRunSlots.prototype.acquire;
|
||||
let arrived!: () => void;
|
||||
const admission = new Promise<void>((resolve) => {
|
||||
arrived = resolve;
|
||||
});
|
||||
let slotSettled = false;
|
||||
const spy = vi
|
||||
.spyOn(KunaRunSlots.prototype, "acquire")
|
||||
.mockImplementation(function (this: KunaRunSlots, signal) {
|
||||
const result = acquire.call(this, signal);
|
||||
void result.then(() => {
|
||||
slotSettled = true;
|
||||
});
|
||||
arrived();
|
||||
return result;
|
||||
});
|
||||
restoreAcquire = () => spy.mockRestore();
|
||||
queued = call(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "main", analysis: "independent_fresh" },
|
||||
caller.signal,
|
||||
).catch(() => undefined);
|
||||
await awaitAcknowledgement(
|
||||
admission,
|
||||
queued,
|
||||
"independent run slot admission",
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(slotSettled).toBe(false);
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(({ argv }) => argv[0] === "decompile"),
|
||||
).toHaveLength(1);
|
||||
caller.abort();
|
||||
await queued;
|
||||
restoreAcquire();
|
||||
restoreAcquire = undefined;
|
||||
expect((await call("close_binary", {})).isError).toBe(true);
|
||||
unproven.release();
|
||||
expect((await call("close_binary", {})).isError).not.toBe(true);
|
||||
await setFakeKunaBehavior(kuna, { functions: FUNCTIONS });
|
||||
expect(
|
||||
(await call("open_binary", { path: target, provider_id: "kuna" }))
|
||||
.isError,
|
||||
).not.toBe(true);
|
||||
const recovered = await call("procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
analysis: "independent_fresh",
|
||||
});
|
||||
expect(recovered.isError).not.toBe(true);
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(({ argv }) => argv[0] === "decompile"),
|
||||
).toHaveLength(2);
|
||||
} finally {
|
||||
caller.abort();
|
||||
unproven.release();
|
||||
await queued;
|
||||
restoreAcquire?.();
|
||||
await harness.close();
|
||||
const closed = await provider.closeOwnedResources();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
});
|
||||
@@ -22,7 +22,7 @@ const it = test.skipIf(process.platform === "win32");
|
||||
it("identifies a caller-selected kuna by version and digest", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-install-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
version: { stdout: "kuna 1.990\n" },
|
||||
version: { stdout: "kuna 1.989\n" },
|
||||
});
|
||||
const link = join(root, "current-kuna");
|
||||
await symlink(kuna, link);
|
||||
@@ -37,7 +37,7 @@ it("identifies a caller-selected kuna by version and digest", async () => {
|
||||
value: {
|
||||
configuration: { command: link, cacheRoot: join(root, "cache") },
|
||||
executable: await realpath(kuna),
|
||||
version: "1.990",
|
||||
version: "1.989",
|
||||
verified: true,
|
||||
// A `#!` launcher reports its interpreter; ownership checks must not expect the path.
|
||||
script: true,
|
||||
@@ -104,14 +104,14 @@ fs.renameSync(process.argv[1] + ".new", process.argv[1]);
|
||||
it("accepts another release but reports it as unverified", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-unverified-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
version: { stdout: "kuna 1.989\n" },
|
||||
version: { stdout: "kuna 1.988\n" },
|
||||
});
|
||||
|
||||
const result = await inspectKunaInstallation({ REA_KUNA_COMMAND: kuna });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { version: "1.989", verified: false },
|
||||
value: { version: "1.988", verified: false },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -182,13 +182,13 @@ it("waits for an aborted pre-session hash before retrying its late close owner",
|
||||
|
||||
it("commits the observed Kuna release and digest to the analysis profile", async () => {
|
||||
const { profile, kuna } = await session("rea-kuna-profile-", {
|
||||
version: { stdout: "kuna 1.990\n" },
|
||||
version: { stdout: "kuna 1.989\n" },
|
||||
});
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
provider: { id: "kuna", name: "Kuna", version: "1.990" },
|
||||
provider: { id: "kuna", name: "Kuna", version: "1.989" },
|
||||
parameters: {
|
||||
kuna_version: "1.990",
|
||||
kuna_version: "1.989",
|
||||
kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||
kuna_command: kuna,
|
||||
jobs: 1,
|
||||
@@ -208,7 +208,7 @@ it("reports an unverified release on observations without binding policy into th
|
||||
});
|
||||
const client = open();
|
||||
const inventory = await run(client, "list_procedures");
|
||||
const warning = "Kuna 1.988 is not REA's verified 1.990 release";
|
||||
const warning = "Kuna 1.988 is not REA's verified 1.989 release";
|
||||
expect(inventory.limitations.join("\n")).toContain(warning);
|
||||
const dossier = await run(client, "analyze_function", { procedure: "main" });
|
||||
expect(dossier.limitations.join("\n")).toContain(warning);
|
||||
@@ -440,9 +440,21 @@ it("reports a failed function and an import slot as unavailable text, not errors
|
||||
procedure: "broken",
|
||||
});
|
||||
expect(broken.result).toBeNull();
|
||||
expect(broken.limitations.at(-1)).toBe(
|
||||
expect(broken.limitations).toContain(
|
||||
"Kuna could not decompile this function: Flow exceeded maximum instructions",
|
||||
);
|
||||
const dossier = await run(client, "analyze_function", {
|
||||
procedure: "broken",
|
||||
});
|
||||
expect(dossier.result).toMatchObject({
|
||||
facet_availability: {
|
||||
pseudocode: {
|
||||
available: false,
|
||||
reason:
|
||||
"Kuna could not decompile this function: Flow exceeded maximum instructions",
|
||||
},
|
||||
},
|
||||
});
|
||||
const imported = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "puts",
|
||||
});
|
||||
@@ -575,6 +587,20 @@ it("keeps answering pseudocode one function at a time when the export fails", as
|
||||
source: { kind: "single-function" },
|
||||
});
|
||||
expect(pseudocode.limitations.at(-1)).toContain("The project export failed");
|
||||
const fresh = await run(client, "procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
analysis: "independent_fresh",
|
||||
});
|
||||
expect(fresh.rawResult).toMatchObject({
|
||||
source: { kind: "single-function" },
|
||||
project_export: {
|
||||
state: "failed",
|
||||
reason: expect.stringContaining("not in recognized object file format"),
|
||||
},
|
||||
});
|
||||
expect(fresh.limitations).toContainEqual(
|
||||
expect.stringContaining("fresh independent single-function"),
|
||||
);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { kunaTimeoutRecovery } from "../../../../src/kuna/KunaCommand.js";
|
||||
|
||||
import {
|
||||
fakeKunaCalls,
|
||||
@@ -306,20 +305,11 @@ it("names the setting that owns a single-function deadline", async () => {
|
||||
message:
|
||||
"kuna decompile did not finish within REA_KUNA_TIMEOUT_MS (300 ms).",
|
||||
remediation: {
|
||||
action: expect.stringMatching(
|
||||
/^Raise REA_KUNA_TIMEOUT_MS .* restart REA.*project export/u,
|
||||
),
|
||||
action:
|
||||
"Raise REA_KUNA_TIMEOUT_MS in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.",
|
||||
},
|
||||
details: { timeout_ms: 300 },
|
||||
});
|
||||
// A finished invocation cannot read a later export from this session.
|
||||
const recovery = kunaTimeoutRecovery("REA_KUNA_TIMEOUT_MS", "decompile");
|
||||
expect(
|
||||
projectAnalysisError(result.error, "invocation").remediation.action,
|
||||
).toBe(recovery.remediationAction);
|
||||
expect(projectAnalysisError(result.error).remediation.action).toBe(
|
||||
recovery.continuingCallerGuidance?.remediationAction,
|
||||
);
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
|
||||
@@ -1246,16 +1246,11 @@ describe("native DMG verification resource availability", () => {
|
||||
exitCode: 1,
|
||||
};
|
||||
|
||||
it("settles an explicit transient refusal within the verification deadline before attaching", async () => {
|
||||
it("retries the explicit transient refusal before attachment and retains both attempts", async () => {
|
||||
const { host, state } = createOmittedInventoryHost();
|
||||
state.inventoryMode = "listed";
|
||||
state.detachAttempts = 4;
|
||||
let now = 0;
|
||||
const clock = vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
onTestFinished(() => clock.mockRestore());
|
||||
const delays = vi.fn(async (milliseconds: number) => {
|
||||
now += milliseconds;
|
||||
});
|
||||
const delays = vi.fn(async () => {});
|
||||
const timeouts: number[] = [];
|
||||
let attempts = 0;
|
||||
const reader = await createAttachedReader(
|
||||
@@ -1271,45 +1266,36 @@ describe("native DMG verification resource availability", () => {
|
||||
if (options === undefined)
|
||||
throw new Error("missing verify deadline");
|
||||
timeouts.push(options.timeoutMs);
|
||||
if (attempts < 5) return transientFailure;
|
||||
if (attempts === 1) return transientFailure;
|
||||
}
|
||||
return host.run(arguments_, signal, options);
|
||||
},
|
||||
},
|
||||
);
|
||||
onTestFinished(() => reader.close());
|
||||
expect(attempts).toBe(5);
|
||||
expect(now).toBeGreaterThan(750);
|
||||
expect(attempts).toBe(2);
|
||||
expect(delays.mock.calls).toEqual([[250]]);
|
||||
expect(timeouts[0]).toBeLessThanOrEqual(120_000);
|
||||
expect(timeouts.at(-1)).toBeLessThan(timeouts[0]!);
|
||||
expect(
|
||||
reader
|
||||
.provenance()
|
||||
.slice(0, 5)
|
||||
.map(({ exit_code }) => exit_code),
|
||||
).toEqual([1, 1, 1, 1, 0]);
|
||||
expect(reader.provenance().slice(0, 1)).toMatchObject([
|
||||
expect(timeouts[1]).toBeLessThanOrEqual(timeouts[0]!);
|
||||
expect(reader.provenance().slice(0, 2)).toMatchObject([
|
||||
{
|
||||
arguments: ["verify", "/tmp/image.dmg"],
|
||||
exit_code: 1,
|
||||
effects: ["read"],
|
||||
},
|
||||
{
|
||||
arguments: ["verify", "/tmp/image.dmg"],
|
||||
exit_code: 0,
|
||||
effects: ["read"],
|
||||
},
|
||||
]);
|
||||
await reader.close();
|
||||
expect(state.attached).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds repeated transient refusals and never attaches an unverified image", async () => {
|
||||
let now = 0;
|
||||
const clock = vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
onTestFinished(() => clock.mockRestore());
|
||||
const run = vi.fn(async () => {
|
||||
now += 40_000;
|
||||
return transientFailure;
|
||||
});
|
||||
const delay = vi.fn(async (milliseconds: number) => {
|
||||
now += milliseconds;
|
||||
});
|
||||
const run = vi.fn(async () => transientFailure);
|
||||
const delay = vi.fn(async () => {});
|
||||
const reader = new NativeDmgArtifactReader("/tmp/image.dmg", process.env, {
|
||||
run,
|
||||
delay,
|
||||
@@ -1320,8 +1306,7 @@ describe("native DMG verification resource availability", () => {
|
||||
});
|
||||
await reader.close();
|
||||
expect(run.mock.calls).toHaveLength(3);
|
||||
expect(now).toBeGreaterThanOrEqual(120_000);
|
||||
expect(delay.mock.calls).toHaveLength(2);
|
||||
expect(delay.mock.calls).toEqual([[250], [500]]);
|
||||
expect(reader.provenance().map(({ exit_code }) => exit_code)).toEqual([
|
||||
1, 1, 1,
|
||||
]);
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("reverse engineering command services", () => {
|
||||
exit_code: 0,
|
||||
output_truncated: false,
|
||||
});
|
||||
expect(result.value.provider.id).toBe("objdump.command");
|
||||
expect(result.value.provider.id).toBe("gnu.objdump");
|
||||
expect(result.value.subject?.local_path).toBe(path);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
|
||||
@@ -292,7 +292,7 @@ it.each([
|
||||
expect(result.error.cause).toBe(code === "ABORT_ERR" ? reason : failure);
|
||||
if (code === "EACCES") {
|
||||
expect(result.error).toMatchObject({
|
||||
providerId: "objdump.command",
|
||||
providerId: "objdump",
|
||||
diagnostics: {
|
||||
boundary: "provider-launch",
|
||||
command: "objdump",
|
||||
|
||||
@@ -39,7 +39,7 @@ it.each(["ENOENT", "EACCES", "EPERM", "ENOTDIR"] as const)(
|
||||
code: "capability_unavailable",
|
||||
retryable: false,
|
||||
details: {
|
||||
provider_id: "objdump.command",
|
||||
provider_id: "objdump",
|
||||
diagnostics: {
|
||||
boundary: "provider-launch",
|
||||
command,
|
||||
|
||||
+3
-26
@@ -2,10 +2,7 @@ import type {
|
||||
JsonSchemaType,
|
||||
JsonSchemaValidator,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import {
|
||||
addFormats,
|
||||
AjvJsonSchemaValidator,
|
||||
} from "@modelcontextprotocol/client/validators/ajv";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import type { ValidateFunction } from "ajv";
|
||||
|
||||
@@ -22,27 +19,9 @@ export const createMcpSchemaValidation = () => {
|
||||
const key = JSON.stringify(schema);
|
||||
let validator = sdkValidators.get(key);
|
||||
if (validator === undefined) {
|
||||
// Keep shared definitions as compiled functions instead of expanding
|
||||
// them at every reference. Other dialects retain SDK dispatch.
|
||||
const provider =
|
||||
schema.$schema === undefined ||
|
||||
schema.$schema === "https://json-schema.org/draft/2020-12/schema"
|
||||
? new AjvJsonSchemaValidator(
|
||||
addFormats(
|
||||
new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: true,
|
||||
validateSchema: false,
|
||||
allErrors: true,
|
||||
inlineRefs: false,
|
||||
code: { optimize: false },
|
||||
}),
|
||||
),
|
||||
)
|
||||
: new AjvJsonSchemaValidator();
|
||||
// Each full schema owns its provider, including dialect dispatch.
|
||||
// Each full schema owns its default provider, including dialect dispatch.
|
||||
// Distinct schemas may legally reuse the same $id in separate servers.
|
||||
validator = provider.getValidator(JSON.parse(key));
|
||||
validator = new AjvJsonSchemaValidator().getValidator(JSON.parse(key));
|
||||
sdkValidators.set(key, validator);
|
||||
}
|
||||
return validator as JsonSchemaValidator<T>;
|
||||
@@ -65,8 +44,6 @@ export const createMcpSchemaValidation = () => {
|
||||
validator = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
inlineRefs: false,
|
||||
code: { optimize: false },
|
||||
}).compile(JSON.parse(key));
|
||||
assertionValidators.set(key, validator);
|
||||
}
|
||||
|
||||
-37
@@ -193,40 +193,6 @@ struct ProcessRunTokenParserProbe {
|
||||
parse: .failure(.invalidBuffer),
|
||||
codeSigningFlags: { 0 }
|
||||
)
|
||||
// A restricted native process can expose an incomplete argv layout before
|
||||
// the parser reaches the withheld environment (CI observed mdworker here).
|
||||
let incompleteRestrictedArguments = [
|
||||
RunTokenReadError.invalidBufferAt(.pathPadding),
|
||||
.invalidBufferAt(.arguments),
|
||||
.appleVectorUnavailable,
|
||||
].map { failure in
|
||||
[UInt32(0x0400_0000), UInt32(0x0000_0800), nil].map { flags in
|
||||
let observation = processRunTokenObservation(
|
||||
pid: 1,
|
||||
parse: .failure(failure),
|
||||
codeSigningFlags: { flags }
|
||||
)
|
||||
return ["state": observation.state, "reason": observation.reason ?? ""]
|
||||
}
|
||||
}
|
||||
let signedReadableTokens = [UInt32(0x0400_0000), UInt32(0x0000_0800)].map { flags in
|
||||
let observation = processRunTokenObservation(
|
||||
pid: 1,
|
||||
parse: .success(realToken),
|
||||
codeSigningFlags: { flags }
|
||||
)
|
||||
return observation.state == "readable" && observation.run_id == "owned-token"
|
||||
}
|
||||
let signedInvalidTokens = [RunTokenReadError.duplicateToken, .invalidTokenEncoding].map { failure in
|
||||
[UInt32(0x0400_0000), UInt32(0x0000_0800)].map { flags in
|
||||
let observation = processRunTokenObservation(
|
||||
pid: 1,
|
||||
parse: .failure(failure),
|
||||
codeSigningFlags: { flags }
|
||||
)
|
||||
return ["state": observation.state, "reason": observation.reason ?? ""]
|
||||
}
|
||||
}
|
||||
let duplicateTokenFailsClosed: Bool
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: fixture(
|
||||
@@ -345,9 +311,6 @@ struct ProcessRunTokenParserProbe {
|
||||
"malformedBuffersRejected": malformedBuffersRejected,
|
||||
"invalidPointerSizeFailsClosed": invalidPointerSizeFailsClosed,
|
||||
"malformedStages": malformedStages,
|
||||
"incompleteRestrictedArguments": incompleteRestrictedArguments,
|
||||
"signedReadableTokens": signedReadableTokens,
|
||||
"signedInvalidTokens": signedInvalidTokens,
|
||||
"malformedObservation": [
|
||||
"state": malformedObservation.state,
|
||||
"reason": malformedObservation.reason as Any? ?? NSNull(),
|
||||
|
||||
@@ -327,7 +327,7 @@ const registerSharedReverseEngineeringEdgeCasesPart3 = (): void => {
|
||||
...(code === "ETIMEDOUT" ? { timeoutMs: 120_000 } : {}),
|
||||
...(code === "EACCES" || code === "EPERM"
|
||||
? {
|
||||
providerId: "objdump.command",
|
||||
providerId: "objdump",
|
||||
diagnostics: {
|
||||
boundary: "provider-launch",
|
||||
command: "objdump",
|
||||
@@ -337,7 +337,7 @@ const registerSharedReverseEngineeringEdgeCasesPart3 = (): void => {
|
||||
: {}),
|
||||
...(code === "unknown"
|
||||
? {
|
||||
providerId: "objdump.command",
|
||||
providerId: "objdump",
|
||||
reason: "Unable to run configured command (unknown error)",
|
||||
}
|
||||
: {}),
|
||||
@@ -425,7 +425,7 @@ const registerSharedReverseEngineeringEdgeCasesPart4 = (): void => {
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
providerId: "objdump.command",
|
||||
providerId: "objdump",
|
||||
diagnostics: {
|
||||
boundary: "provider-launch",
|
||||
command: "objdump",
|
||||
@@ -535,7 +535,7 @@ const registerSharedReverseEngineeringEdgeCasesPart5 = (): void => {
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
providerId: "objdump.command",
|
||||
providerId: "objdump",
|
||||
reason: "Unable to run configured command (ENOENT)",
|
||||
cause: { message: "launch failed" },
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user