diff --git a/experiments/atomic-codemode/README.md b/experiments/atomic-codemode/README.md index a2de2815eb..6e7e5be703 100644 --- a/experiments/atomic-codemode/README.md +++ b/experiments/atomic-codemode/README.md @@ -19,6 +19,11 @@ The [capability map](capability-map.md) distinguishes verified task coverage, experimental exporter requirements and remaining unknowns before interpreting adapter comparisons. +The [function-boundary study](function-boundary-study.md) adds independent native +prototype entries and return descriptors, separated from raw input varnodes. +Initial real SSH checks expose different inventories; final native and model +verification is recorded there as it completes. + The [path-query study](path-query-study.md) adds a shared, bounded recovered-CFG witness query with exclusions and paging. Both actual MCP adapters verify the same four real SQLite witnesses without requiring the model to fetch the entire @@ -147,7 +152,7 @@ the ledger now records actual model responses and client dispatch/wait calls. owner. Recovered operations use `origin_address` and blocks `origin_start`; these locations cannot be substituted for machine branch destinations. The value-origin trial demonstrates why this boundary matters. The experiment -now has sixteen fact methods, still behind the same four-tool Code Mode +now has seventeen fact methods, still behind the same four-tool Code Mode catalog. `calls` connects recovered call operations to native target specifications and diff --git a/experiments/atomic-codemode/api.mjs b/experiments/atomic-codemode/api.mjs index 5e7b195b1c..122d36050b 100644 --- a/experiments/atomic-codemode/api.mjs +++ b/experiments/atomic-codemode/api.mjs @@ -236,7 +236,7 @@ export const methods = { summary: "Select SSA values by ID or exact space/offset/width; keep versions, inputs and constants distinct, without alias inference.", description: - "Read native SSA values by IDs or exact locations [{space,offset,size_bytes}], with width required. Locations match retained metadata, including const/unique spaces, not pointee contents, aliases or live memory. Filters intersect; IDs retain caller order, otherwise locations retain caller order and values retain native order per location. Empty IDs/locations select none. Null-space values cannot resolve a location and stay explicit in location_coverage; unretained_locations means no known retained coordinate match, not absent machine storage. Scope may be a full graph, bounded call-rooted definition projection, or call-boundary values only; is_input filters retained operand/output values, not an original source signature. Offset is an address-space offset, not a computed arbitrary-width value. Width, constants, function inputs, annotation status and defining operation are separate facts. Requires the pinned native exporter.", + "Read native SSA values by IDs or exact locations [{space,offset,size_bytes}], with width required. Locations match retained metadata, including const/unique spaces, not pointee contents, aliases or live memory. Filters intersect; IDs retain caller order, otherwise locations retain caller order and values retain native order per location. Empty IDs/locations select none. Null-space values cannot resolve a location and stay explicit in location_coverage; unretained_locations means no known retained coordinate match, not absent machine storage. Scope may be a full graph, bounded call-rooted definition projection, or call-boundary values only; is_input filters the raw native input flag, which can also mark globals or stack values; it is not formal-parameter membership. Use parameters for the separate native recovered prototype, which also does not prove an original source signature. Offset is an address-space offset, not a computed arbitrary-width value. Width, constants, function inputs, annotation status and defining operation are separate facts. Requires the pinned native exporter.", }, { snapshot, @@ -312,6 +312,38 @@ export const methods = { "blocks", "await rea.blocks({snapshot:s,ids:[op.block]})", ), + parameters: definition( + { + summary: + "Select native recovered formal parameter and return storage, convention and recovery flags; raw input varnodes are not formals.", + description: + "Read the captured native function prototype: paged parameter entries, return descriptor, native convention label, locks, variadic and recovery-error flags. IDs/indices belong to this snapshot's recovered prototype, not proven source parameter ordinals or original signature. Native is_input also marks stack/global input varnodes; it is not formal membership. Select indices in caller order and optional fields; defaults omit display names/type strings. Output uses the same field selection. Null storage/type stays unknown; explicit is_void is independent of storage absence. Storage matches across snapshots require exact artifact/callee identity and ABI reasoning; stack rebasing, aliases, split/join storage, runtime binding and source signatures remain unproved. Requires a captured parameters facet, independently of full CFG/calls. Older experimental captures explicitly report not-captured; no synthetic empty signature.", + }, + { + snapshot, + indices: z.array(z.number().int().nonnegative()).max(128).optional(), + fields: z + .array( + z.enum([ + "name", + "type_name", + "size_bytes", + "is_void", + "storage", + "type_locked", + "name_locked", + "size_locked", + "this_pointer", + "indirect_storage", + "hidden_return", + ]), + ) + .optional(), + ...page, + }, + "parameters", + 'await rea.parameters({snapshot:s,indices:[0],fields:["storage","size_bytes","is_void","type_name"]})', + ), path: definition( { summary: diff --git a/experiments/atomic-codemode/capability-map.md b/experiments/atomic-codemode/capability-map.md index 736487940b..e83a10ade6 100644 --- a/experiments/atomic-codemode/capability-map.md +++ b/experiments/atomic-codemode/capability-map.md @@ -1,7 +1,7 @@ # Capability and comparison scope -The experiment has sixteen fact methods behind a shared SDK/CLI owner, exposed -as nineteen direct MCP tools or four Code Mode tools. The structured IR/call/ +The experiment has seventeen fact methods behind a shared SDK/CLI owner, exposed +as twenty direct MCP tools or four Code Mode tools. The structured IR/call/ projection/switch facts require the explicitly pinned experimental Kuna patch; they are not capabilities added to stock Kuna or integrated REA by registration alone. Other providers are not wired to this experimental service. diff --git a/experiments/atomic-codemode/function-boundary-oracle.md b/experiments/atomic-codemode/function-boundary-oracle.md new file mode 100644 index 0000000000..22664c1b89 --- /dev/null +++ b/experiments/atomic-codemode/function-boundary-oracle.md @@ -0,0 +1,90 @@ +# Native function-boundary oracle + +Freeze the question, oracle, runtime and complete catalogs before real models. +Question contains the observed formats but no addresses/counts/native IDs. +The target is the same stripped Debian OpenSSH client as the warning study: +`/usr/bin/ssh`, SHA-256 +`04f2ff5f506a3f332e7adeb1478a4c551ae74acdd328e6fb5c2495664d4064e6`. +Use the new pinned experimental engine for both lanes, not a stock capability +claim. Verify the final engine's complete prior function records unchanged except +for the added independent parameters facet before scoring model answers. + +References select banner function `0x25cb0` (banner `0xc8628`, xref `0x25d14`) +and strict-diagnostic caller `0x280c0` (format `0xc9018`, xref `0x28e6c`). +The selected direct call is `0x28d53 -> 0x25cb0`; another incoming caller is +not the requested caller. These are native/reference observations, not guessed +original source names. Global reference discovery remains incomplete. + +Both native prototypes report model label `__stdcall`, model_unknown false, +inputs_locked/output_locked false, variadic false, input_errors/output_errors +false. The label is not evidence for a Windows ABI, original signature or +runtime binding; flags report native state, not model correctness. Every native +parameter/output in these two captures has type/name/size locks, this_pointer, +indirect_storage and hidden_return false. + +Banner prototype has four entries, all eight bytes: + +| Native index | Storage | +| ------------ | ------------------- | +| 0 | register:0x38 (RDI) | +| 1 | register:0x30 (RSI) | +| 2 | register:0x10 (RDX) | +| 3 | register:0x8 (RCX) | + +Its output is explicitly is_void true, size 0 and null storage; native type_name +is an empty string, which must not erase the explicit void observation. +Its complete retained graph has three is_input values. Only native entries 0 +and 3 have exact retained input matches; entries 1/2 do not. No original source +arity or absence of source parameters follows from either count/missing match. + +Caller prototype has ten native entries: + +| Native index | Storage / bytes | +| ------------ | ----------------- | +| 0 | register:0x38 / 8 | +| 1 | register:0x30 / 8 | +| 2 | register:0x10 / 8 | +| 3 | register:0x8 / 2 | +| 4 | register:0x80 / 8 | +| 5 | register:0x88 / 4 | +| 6 | stack:0x8 / 4 | +| 7 | stack:0x10 / 4 | +| 8 | stack:0x18 / 4 | +| 9 | stack:0x20 / 8 | + +Its output is is_void false, eight-byte register:0x0 (RAX), type_name xunknown8. +This is a recovered return descriptor, not proof of original 64-bit C return +type. Its complete graph has 44 is_input values; every native parameter tuple +has an exact input match. Extra input varnodes do not thereby become formals. +Preserve stack coordinates as native values without inventing a cross-call +stack rebase, alias/subregister/split/join interpretation or source ordinals. + +Selected caller CALL `0x28d53:779` passes value `v10000a9cf` at recovered input 1 in RDI +(register:0x38 / 8), matching banner native slot 0's storage. The banner's +matched input value is `v30000002e`; its first helper call `0x25cc3:1e` +uses that identity at input 1 and targets `0x42c20`. Exact caller value IDs and +all matched callee/caller input IDs must be checked against the actual delivered +sources, retaining separate snapshot scopes even when storage or IDs coincide. +[Existing independent machine evidence](ssh-warning-oracle.md) verifies +`MOV RDI,R15` at `0x28d50`, CALL `0x28d53 -> 0x25cb0`, incoming RDI saved +to R13 at `0x25cbc`, and helper CALL `0x25cc3 -> 0x42c20` with RDI unchanged. +A matching register and known direct target support this selected transfer; +they are not cross-function SSA equality or general aliases/runtime feasibility. + +Score the complete answer for owner/caller, both full native parameter +inventories and return/flag semantics, raw input counts and exact matches, +explicit missing/unknown interpretation, selected cross-function pointer and +machine corroboration, source identities, target digest, scope and close. +Both routes get the same question, complete twenty/four-tool catalogs, requested +`gpt-6.1-sol/high` via Codex 0.161.0, fresh isolated leases/private caches and +common discovery/envelope/wait guidance. Charge whole-task cumulative/cached/ +uncached input, output/reasoning, model responses, calls/retries/waits, wall/CPU +and kernel family memory. Preserve every attempt. No traditional-provider cell +is claimed for these native-only model facts. Remote model build/compute, +OS/model caches and host contention remain uncontrolled. No PR/main merge. + +The banner's two exact retained input matches are slot 0 `v30000002e` and +slot 3 `v3000004b5`. Caller slots 0–9 match respectively `v30000005b`, +`v300000072`, `v30000007a`, `v700000032`, `v300000008`, `v500000082`, +`v50000b767`, `v50000b044`, `v500002b57`, `v10001374c`. These are evidence- +scoped native IDs, not cross-function globally stable object identities. diff --git a/experiments/atomic-codemode/function-boundary-question.txt b/experiments/atomic-codemode/function-boundary-question.txt new file mode 100644 index 0000000000..f510e3ce6d --- /dev/null +++ b/experiments/atomic-codemode/function-boundary-question.txt @@ -0,0 +1,38 @@ +Analyze {{artifact_path}} statically from these two observed formats: +- REMOTE HOST IDENTIFICATION HAS CHANGED +- Host key for %.200s has changed and you have requested strict checking. + +Identify the function that passes the banner format to its logging/formatting +call, and the caller that owns the strict-checking diagnostic. Establish both +from real references, not guessed source names. Reuse one retained native +capture per selected function and selected structured facts. + +For both functions, inspect the native recovered function prototype separately +from raw input varnodes. Report its parameter-entry count, each native parameter +index with storage/width, return descriptor including explicit void/type/storage +knowledge, native model label, lock state and recovery-error flags. The model +label and recovered entries are not a proven original ABI or source signature. +Keep explicit unknowns and native hidden/indirect-storage flags. + +Count retained values marked is_input in each complete recovered graph. For +each native parameter storage tuple, determine whether an exact space/offset/ +width match exists among those retained input values, preserving matched value +IDs and explicitly reporting missing matches. Do not treat missing retained +matches as proof that a source parameter is absent. Do the two inventories +mean the same thing? Can either count establish original source arity? Use +the observed facts to explain the distinction, without parsing a C declaration. + +At the selected direct caller-to-banner-function call, show the target and the +passed pointer's recovered operand index, value identity, storage and width. +Relate that transfer to the callee's matching native parameter entry and its +first helper-call use. Keep the two snapshots' SSA identities separate; matching +storage is not cross-function SSA equality or a general alias proof. Verify +the selected pointer setup/call and callee helper use with focused machine +instructions, including actual call targets and passing registers. + +Return a focused answer with real Evidence IDs, target digest and necessary +coverage. Distinguish native recovered model observations, storage-based +connections and machine corroboration from original signature/ABI/runtime +claims. Preserve unresolved stack rebasing, split/join/subregister mappings, +aliases, runtime binding and feasibility. Do not return complete C/graph reports +or execute the target. Close the target lease. diff --git a/experiments/atomic-codemode/function-boundary-study.md b/experiments/atomic-codemode/function-boundary-study.md new file mode 100644 index 0000000000..0926655df8 --- /dev/null +++ b/experiments/atomic-codemode/function-boundary-study.md @@ -0,0 +1,83 @@ +# Native function boundaries as independent facts + +The selected cross-function SSH task needs to distinguish recovered formal +prototype entries from raw input varnodes. The existing value is_input flag is +not formal membership: it also includes stack/global inputs. Recovering an +original source signature from either inventory would be unsupported. + +A new shared `parameters` query selects native prototype indices, fields and +pages, with a separately projected return descriptor, native model label, +lock/variadic/recovery-error flags and retained Evidence. Defaults omit display +names/type strings. Explicit void, unknown storage/type, known missing native +indices, unavailable store and malformed capture remain distinct. Source arity, +ABI truth, runtime binding, aliases, stack rebasing and split/join mappings stay +unknown. Call-site argument storage is a separate fact. The analyst can compose +it with selected callee prototype/storage/value facts while retaining both +snapshots' identities and the selected machine corroboration. + +One native function-prototype owner exports an independent parameters facet. +Its extraction does not depend on full CFG/call enumeration, and optional encoded +output budgeting retains typed omission reasons and readable code. The renderer +restores owned JSON fields by identity rather than a numeric position invalidated +by removing other independent facets. Native address/storage serialization and +JS storage contracts each have one shared owner for call sites and function +prototypes; parameters do not depend on a call-record schema's nested shape. +Shared application admission keeps the same named methods for SDK, CLI and both +MCP adapters. There are seventeen methods, twenty direct tools and four Code +Mode tools. No integration with stock Kuna or other providers is claimed. + +The native overlay is pinned to upstream +`fe01d6294eb0d1053ce343c8943646df88e143fc`; the REA experiment was refreshed from +main `f22e8e726` before this change. Forward/reverse patch checks pass against +the pinned base/current source. All generated binaries, captures and caches stay +local. No PR or merge into main is intended. + +## Current executed evidence + +The first real SSH candidate engine is +`6aae471bef4434eb3acea98b497c6df58e3a9fa71021178c89bbbc3d96109080`. +Actual Code Mode MCP checks both functions, full/selected/empty parameter +queries, paging, exact retained input-location matches, repeated +metadata and complete source fidelity: removing only the added parameters facet +leaves each complete prior native function record unchanged. Banner `0x25cb0` +has four native prototype slots but three retained is_input values; slots 1/2 +have no exact retained input match. Caller `0x280c0` has ten prototype slots and +44 raw input values, with all ten native storage tuples matched. Native labels +and flags are observations, not original source arity/ABI proof. Full expected +facts and limits are in the [oracle](function-boundary-oracle.md). + +The JS suite passes 94 tests, including projection-only/unavailable/malformed +prototype reads, native identities/order/paging, explicit void versus unknown, +immutable SDK metadata and runtime/JSON Schema agreement. Both actual converted +MCP catalogs pass named schema/admission checks. Two native constructor tests +pass: absent stores stay unavailable, and native slots/void/return storage/locks +remain distinct. Full native workspace/corpus gates are unexecuted. + +| Executed stage | Family wall s | CPU s | Kernel peak MiB | +| ------------------------------------- | ------------: | ------: | --------------: | +| Latest-main cached runtime build | 43.328 | 41.531 | 3,057.8 | +| Initial 94 JS tests | 29.271 | 9.085 | 82.1 | +| Initial converted catalog checks | 10.592 | 2.281 | 136.5 | +| Candidate real Code Mode SSH verifier | 8.676 | 8.439 | 319.9 | +| Candidate opt1 native build | 447.973 | 414.263 | 2,174.7 | +| Native constructor tests, two passed | 264.382 | 264.031 | 2,815.2 | + +All completed rows have OOM/kill zero and verified payload cleanup, under one +CPU / 4 GiB hard / 3 GiB early-stop bounds. Kernel peaks include charged file +cache, not RSS. JS/schema validation overlapped bounded compilation on the same +CPU and is not an analysis-speed benchmark. The initial native build accidentally +selected default release optimization rather than the original opt1 profile; it +was deliberately interrupted after 135.878 seconds and its unique owned scope +verified inactive. Its final counters are unavailable. The corrected opt1 +candidate build above completed; no interrupted build is reported as passing. + +The shared storage/renderer changes still need final native JSON-capacity tests, +a final rebuilt/pinned engine, real SDK/MCP checks on that final engine and the +real-model pair. The [question](function-boundary-question.txt) contains only +formats and requirements, no addresses/counts/IDs. Freeze it, the oracle and +runtime before both isolated agents; generate complete manifests from the actual +catalog. Use the same requested model/settings and common guidance, preserve all +attempts and review complete answers/source identities. Report whole-task +cumulative/cached/uncached input, output/reasoning, responses, calls/retries/waits, +wall/CPU/family memory, target/engine/runtime stability and cleanup. Candidate +checks do not establish those pending model outcomes or a cost advantage. diff --git a/experiments/atomic-codemode/kuna-native-ir.patch b/experiments/atomic-codemode/kuna-native-ir.patch index 2ec9a073ca..88581e1340 100644 --- a/experiments/atomic-codemode/kuna-native-ir.patch +++ b/experiments/atomic-codemode/kuna-native-ir.patch @@ -167,10 +167,10 @@ index 00000000..48631d39 +} diff --git a/decompiler/crates/kuna-cli/src/atomic_json.rs b/decompiler/crates/kuna-cli/src/atomic_json.rs new file mode 100644 -index 00000000..6005472b +index 00000000..08deb2e5 --- /dev/null +++ b/decompiler/crates/kuna-cli/src/atomic_json.rs -@@ -0,0 +1,456 @@ +@@ -0,0 +1,497 @@ +//! Opt-in atomic documents share the ordinary record producer and JSON writer. +//! A graph that cannot fit must not discard otherwise readable reconstructed C. + @@ -179,10 +179,11 @@ index 00000000..6005472b +// Ownership is moved out while trying bounded alternatives, never cloned. +struct RemovedFacet { + function: usize, -+ field: usize, + value: (String, Json), + added_error: bool, +} ++// JSON object member order is not facet identity. Restore owned entries by key, ++// never by an ordinal invalidated when another independent facet was removed. +#[derive(Clone, Copy)] +enum Omission { + Capacity, @@ -218,7 +219,6 @@ index 00000000..6005472b + } + removed.push(RemovedFacet { + function, -+ field, + value, + added_error, + }); @@ -236,7 +236,7 @@ index 00000000..6005472b + let error = format!("{}_error", facet.value.0); + fields.retain(|(key, _)| key != &error); + } -+ fields.insert(facet.field, facet.value); ++ fields.push(facet.value); + } + } + } @@ -293,27 +293,24 @@ index 00000000..6005472b +} +// Reclaim a saved independent facet when other omissions have made room. +// Only bounded encoded strings coexist here, never cloned graph/facet trees. -+fn bounded_reclaiming( -+ document: &mut Json, -+ budget: usize, -+ name: &str, -+ saved: &mut Vec, -+) -> Option { -+ let base = bounded(document, budget)?; -+ if saved.is_empty() { -+ return Some(base); -+ } ++fn reclaim(document: &mut Json, budget: usize, name: &str, saved: &mut Vec, base: String) -> String { ++ if saved.is_empty() { return base; } + restore_facet(document, std::mem::take(saved)); -+ if let Some(complete) = bounded(document, budget) { -+ return Some(complete); -+ } ++ if let Some(complete) = bounded(document, budget) { return complete; } + *saved = take_facet(document, name, budget + 1, Omission::Capacity); -+ Some(base) ++ base ++} ++fn bounded_reclaiming(document: &mut Json, budget: usize, name: &str, saved: &mut Vec) -> Option { ++ let base = bounded(document, budget)?; ++ Some(reclaim(document, budget, name, saved, base)) +} +pub(crate) fn render(mut document: Json, limit: usize) -> Result { + let budget = limit + .checked_sub(1) + .ok_or("atomic JSON output budget is zero")?; ++ // A newly added independent facet must not displace established graph/call ++ // coverage. Reclaim it only after the existing owner selects a fitting result. ++ let mut parameters = take_facet(&mut document, "atomic_parameters", limit, Omission::Capacity); + // A full graph already owns these operation/value facts. Keep its bounded + // projection aside in case encoded size later makes that graph unavailable. + let projection = take_facet( @@ -323,14 +320,14 @@ index 00000000..6005472b + Omission::RedundantWithGraph, + ); + if let Some(text) = bounded(&document, budget) { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + let originals = compress_graphs(&mut document, budget); + let mut alternatives = if originals.is_empty() { + Vec::new() + } else { + if let Some(text) = bounded(&document, budget) { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + exchange_graphs(&mut document, originals) + }; @@ -339,12 +336,12 @@ index 00000000..6005472b + let mut switches = take_facet(&mut document, "atomic_switches", limit, Omission::Capacity); + if !switches.is_empty() { + if let Some(text) = bounded(&document, budget) { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + if !alternatives.is_empty() { + let originals = exchange_graphs(&mut document, alternatives); + if let Some(text) = bounded(&document, budget) { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + alternatives = exchange_graphs(&mut document, originals); + } @@ -354,14 +351,14 @@ index 00000000..6005472b + if let Some(text) = + bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + if !alternatives.is_empty() { + drop(exchange_graphs(&mut document, alternatives)); + if let Some(text) = + bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + } + } @@ -370,12 +367,12 @@ index 00000000..6005472b + restore_facet(&mut document, projection); + restore_facet(&mut document, std::mem::take(&mut switches)); + if let Some(text) = bounded(&document, budget) { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + switches = take_facet(&mut document, "atomic_switches", limit, Omission::Capacity); + if !switches.is_empty() { + if let Some(text) = bounded(&document, budget) { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + } + // Independent calls retain priority when the combined fallback is too big. @@ -387,13 +384,13 @@ index 00000000..6005472b + ); + if let Some(text) = bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + restore_facet(&mut document, projection); + take_facet(&mut document, "atomic_calls", limit, Omission::Capacity); + if let Some(text) = bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) + { -+ return Ok(text); ++ return Ok(reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)); + } + take_facet( + &mut document, @@ -401,7 +398,8 @@ index 00000000..6005472b + limit, + Omission::Capacity, + ); -+ bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches).ok_or_else(|| { ++ bounded_reclaiming(&mut document, budget, "atomic_switches", &mut switches) ++ .map(|text| reclaim(&mut document, budget, "atomic_parameters", &mut parameters, text)).ok_or_else(|| { + format!("atomic JSON exceeds {limit} byte capacity even without recovered facets") + }) +} @@ -627,6 +625,49 @@ index 00000000..6005472b + } + } +} ++ ++#[cfg(test)] ++mod parameter_facet_tests { ++ use super::*; ++ fn document(graph: usize, calls: usize, parameters: usize) -> Json { ++ Json::Object(vec![("functions".into(),Json::Array(vec![Json::Object(vec![ ++ ("code".into(),Json::Str("readable C".into())), ++ ("atomic_facts".into(),Json::Str("g".repeat(graph))), ++ ("atomic_calls".into(),Json::Str("c".repeat(calls))), ++ ("atomic_parameters".into(),Json::Str("p".repeat(parameters))), ++ ])]))]) ++ } ++ #[test] ++ fn oversized_parameters_do_not_displace_existing_graph_and_calls() { ++ let text=render(document(400,20,3000),900).unwrap(); ++ let value:serde_json::Value=serde_json::from_str(&text).unwrap();let f=&value["functions"][0]; ++ assert_eq!(f["atomic_facts"].as_str().unwrap().len(),400); ++ assert_eq!(f["atomic_calls"].as_str().unwrap().len(),20); ++ assert!(f.get("atomic_parameters").is_none()); ++ assert!(f["atomic_parameters_error"].is_string()); ++ } ++ #[test] ++ fn independent_small_parameters_survive_when_graph_and_calls_do_not_fit() { ++ let text=render(document(3000,3000,12),900).unwrap(); ++ assert!(text.len()<=900); ++ let value:serde_json::Value=serde_json::from_str(&text).unwrap();let f=&value["functions"][0]; ++ assert_eq!(f["code"],"readable C"); ++ assert_eq!(f["atomic_parameters"].as_str().unwrap().len(),12); ++ assert!(f.get("atomic_parameters_error").is_none()); ++ } ++ #[test] ++ fn restore_is_owned_by_field_identity_after_other_fields_are_removed() { ++ let mut doc=document(10,10,10); ++ let parameters=take_facet(&mut doc,"atomic_parameters",1000,Omission::Capacity); ++ take_facet(&mut doc,"atomic_facts",1000,Omission::Capacity); ++ take_facet(&mut doc,"atomic_calls",1000,Omission::Capacity); ++ restore_facet(&mut doc,parameters); ++ let text=bounded(&doc,1000).unwrap(); ++ let value:serde_json::Value=serde_json::from_str(&text).unwrap(); ++ assert_eq!(value["functions"][0]["atomic_parameters"],"p".repeat(10)); ++ assert!(value["functions"][0].get("atomic_parameters_error").is_none()); ++ } ++} diff --git a/decompiler/crates/kuna-cli/src/decompile.rs b/decompiler/crates/kuna-cli/src/decompile.rs index ad249b04..62074996 100644 --- a/decompiler/crates/kuna-cli/src/decompile.rs @@ -765,7 +806,7 @@ index ad249b04..62074996 100644 failure = Some(format!("--assert directive rejected in {}", args.binary)); } diff --git a/decompiler/crates/kuna-cli/src/decompile_all.rs b/decompiler/crates/kuna-cli/src/decompile_all.rs -index 698c7243..21147ba5 100644 +index 698c7243..c1dceab2 100644 --- a/decompiler/crates/kuna-cli/src/decompile_all.rs +++ b/decompiler/crates/kuna-cli/src/decompile_all.rs @@ -744,6 +744,7 @@ fn run_jobs_worker(args: &Args) -> Result<(), String> { @@ -821,20 +862,21 @@ index 698c7243..21147ba5 100644 /// [`render_result_json`] plus the inventory `total` a NARROWED whole-binary run /// answers with. /// -@@ -2152,6 +2168,12 @@ fn result_json( +@@ -2152,6 +2168,13 @@ fn result_json( // Always present, `[]` unless the option is on. ("types".into(), types), ]; + if let Some(detail) = f.detail.as_ref() { + append_atomic_facet(&mut record, "atomic_facts", detail.atomic_facts.as_ref()); + append_atomic_facet(&mut record, "atomic_calls", detail.atomic_calls.as_ref()); ++ append_atomic_facet(&mut record, "atomic_parameters", detail.atomic_parameters.as_ref()); + append_atomic_facet(&mut record, "atomic_switches", detail.atomic_switches.as_ref()); + append_atomic_facet(&mut record, "atomic_projection", detail.atomic_projection.as_ref()); + } if !f.stack_objects.is_empty() { record.push(("stack_objects".into(), Json::Array( f.stack_objects.iter().map(stack_object_json).collect(), -@@ -3519,3 +3541,16 @@ mod triage_tests { +@@ -3519,3 +3542,16 @@ mod triage_tests { } } @@ -1151,21 +1193,22 @@ index e40b18c9..b8cae233 100644 }; diff --git a/decompiler/crates/kuna-console/src/inspect.rs b/decompiler/crates/kuna-console/src/inspect.rs -index 74024b10..a41465a6 100644 +index 74024b10..9e36d55f 100644 --- a/decompiler/crates/kuna-console/src/inspect.rs +++ b/decompiler/crates/kuna-console/src/inspect.rs -@@ -24,6 +24,10 @@ pub const FUNCTION_ROW_CAP: usize = 4096; +@@ -24,6 +24,11 @@ pub const FUNCTION_ROW_CAP: usize = 4096; pub struct FuncDetail { pub tokens: Vec, pub tokens_error: Option, + pub atomic_facts: Option>, + pub atomic_calls: Option>, ++ pub atomic_parameters: Option>, + pub atomic_switches: Option>, + pub atomic_projection: Option>, } impl FuncDetail { -@@ -40,13 +44,13 @@ impl FuncDetail { +@@ -40,13 +45,13 @@ impl FuncDetail { ) -> Self { let mut tokens = kuna_srcmap::resolve(fd, var_refs, raw, untrimmed, variables); if let Err(error) = kuna_srcmap::verify(&tokens, code) { @@ -1182,7 +1225,7 @@ index 74024b10..a41465a6 100644 } diff --git a/decompiler/crates/kuna-console/src/project.rs b/decompiler/crates/kuna-console/src/project.rs -index bc401db2..7b7c7c24 100644 +index bc401db2..2ef18e27 100644 --- a/decompiler/crates/kuna-console/src/project.rs +++ b/decompiler/crates/kuna-console/src/project.rs @@ -245,6 +245,8 @@ pub struct DecompileOptions { @@ -1218,7 +1261,7 @@ index bc401db2..7b7c7c24 100644 want_pointer_arguments, .. } = *opts; -@@ -838,11 +843,23 @@ pub fn decompile_pulled( +@@ -838,11 +843,24 @@ pub fn decompile_pulled( } } let var_refs = provenance.apply_to_variables_with_refs(&fd, &mut variables); @@ -1230,6 +1273,7 @@ index bc401db2..7b7c7c24 100644 }); + if want_atomic_facts { + let detail = detail.get_or_insert_with(Default::default); ++ detail.atomic_parameters = Some(kuna_decomp::atomic_parameters::collect(fd.get_func_proto())); + let membership = kuna_decomp::atomic_membership::inspect(&fd); + detail.atomic_calls = Some(membership.as_ref().map_err(Clone::clone) + .and_then(|m| kuna_decomp::atomic_calls::collect_all(&fd, m))); @@ -1315,19 +1359,19 @@ index 8aad0172..2f69df2d 100644 +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs new file mode 100644 -index 00000000..2b7ced64 +index 00000000..fa04cafc --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs -@@ -0,0 +1,216 @@ +@@ -0,0 +1,189 @@ +//! Recovered call relations. Argument values and passing storage are separate. +use crate::{ + atomic_membership::{Membership, MAX_CALLS}, + atomic_values::{self, operation_id, value_id, ValueFact}, + context::OpId, ++ atomic_storage::{location, storage, Location, Storage}, + fspec::{FuncCallSpecs, OFFSET_UNKNOWN}, + funcdata::Funcdata, +}; -+use kuna_base::address::Address; +use serde::Serialize; +use std::collections::{BTreeSet, HashMap}; + @@ -1408,17 +1452,6 @@ index 00000000..2b7ced64 +} + +#[derive(Debug, Clone, Serialize)] -+pub struct Location { -+ pub space: String, -+ pub offset: String, -+} -+#[derive(Debug, Clone, Serialize)] -+pub struct Storage { -+ #[serde(flatten)] -+ pub location: Location, -+ pub size_bytes: i32, -+} -+#[derive(Debug, Clone, Serialize)] +pub struct Argument { + pub input_index: i32, + pub value_id: Option, @@ -1449,22 +1482,6 @@ index 00000000..2b7ced64 + pub prototype: Option, + pub stack_offset: Option, +} -+fn location(address: &Address) -> Option { -+ address.get_space().map(|space| Location { -+ space: space.get_name().to_string(), -+ offset: format!("0x{:x}", address.get_offset()), -+ }) -+} -+fn storage(address: &Address, size_bytes: i32) -> Option { -+ if size_bytes <= 0 { -+ return None; -+ } -+ location(address).map(|location| Storage { -+ location, -+ size_bytes, -+ }) -+} -+ +fn collect(fd: &Funcdata, key: OpId, spec: Option<&FuncCallSpecs>) -> Result { + let op = fd.obank().get(key).ok_or("stale call operation")?; + let arguments = (1..op.num_input()) @@ -1904,6 +1921,167 @@ index 00000000..30d6be39 + atomic_values::collect(fd, std::mem::take(&mut self.keys), &mut self.text) + } +} +diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_parameters.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_parameters.rs +new file mode 100644 +index 00000000..82a34a49 +--- /dev/null ++++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_parameters.rs +@@ -0,0 +1,155 @@ ++//! Native function prototype facts, independent of raw input varnodes/call count. ++use crate::{ ++ atomic_storage::{storage, Storage}, ++ atomic_values::TextBudget, ++ dtype::type_metatype, ++ fspec::{FuncProto, ProtoParameter}, ++}; ++use serde::Serialize; ++ ++// Bound row expansion independently of the encoded document/text budgets. ++pub const MAX_PARAMETERS: usize = 12_000; ++#[derive(Debug, Clone, Serialize)] ++pub struct Parameter { ++ pub id: String, ++ pub native_index: Option, ++ pub name: String, ++ pub type_name: Option, ++ pub size_bytes: i32, ++ pub is_void: Option, ++ pub storage: Option, ++ pub type_locked: bool, ++ pub name_locked: bool, ++ pub size_locked: bool, ++ pub this_pointer: bool, ++ pub indirect_storage: bool, ++ pub hidden_return: bool, ++} ++#[derive(Debug, Clone, Serialize)] ++pub struct Parameters { ++ pub schema_version: u32, ++ pub model: Option, ++ pub model_unknown: Option, ++ pub inputs_locked: bool, ++ pub output_locked: bool, ++ pub variadic: bool, ++ pub input_errors: bool, ++ pub output_errors: bool, ++ pub items: Vec, ++ pub output: Parameter, ++} ++fn parameter( ++ p: &dyn ProtoParameter, ++ index: Option, ++ text: &mut TextBudget, ++) -> Result { ++ text.charge(p.get_name().len())?; ++ if let Some(t) = p.get_type() { ++ text.charge(t.get_name().len())?; ++ } ++ Ok(Parameter { ++ id: index.map_or_else(|| "return".into(), |i| format!("parameter:{i}")), ++ native_index: index, ++ name: p.get_name().into(), ++ type_name: p.get_type().map(|t| t.get_name().into()), ++ size_bytes: p.get_size(), ++ is_void: p ++ .get_type() ++ .map(|t| t.get_metatype() == type_metatype::TYPE_VOID), ++ storage: storage(&p.get_address(), p.get_size()), ++ type_locked: p.is_type_locked(), ++ name_locked: p.is_name_locked(), ++ size_locked: p.is_size_type_locked(), ++ this_pointer: p.is_this_pointer(), ++ indirect_storage: p.is_indirect_storage(), ++ hidden_return: p.is_hidden_return(), ++ }) ++} ++pub fn collect(proto: &FuncProto) -> Result { ++ if !proto.has_store() { ++ return Err("native function prototype store is unavailable".into()); ++ } ++ let count = ++ usize::try_from(proto.num_params()).map_err(|_| "invalid native parameter count")?; ++ if count > MAX_PARAMETERS { ++ return Err("atomic parameter row capacity exceeded".into()); ++ } ++ let mut text = TextBudget::new(2 * 1024 * 1024); ++ if proto.has_model() { ++ text.charge(proto.get_model_name().len())?; ++ } ++ let mut items = Vec::with_capacity(count); ++ for i in 0..count { ++ let p = proto ++ .get_param(i as i32) ++ .ok_or("missing native parameter slot")?; ++ items.push(parameter(p, Some(i as i32), &mut text)?); ++ } ++ Ok(Parameters { ++ schema_version: 1, ++ model: proto.has_model().then(|| proto.get_model_name().into()), ++ model_unknown: proto.has_model().then(|| proto.is_model_unknown()), ++ inputs_locked: proto.is_input_locked(), ++ output_locked: proto.is_output_locked(), ++ variadic: proto.is_dotdotdot(), ++ input_errors: proto.has_input_errors(), ++ output_errors: proto.has_output_errors(), ++ items, ++ output: parameter(proto.get_output(), None, &mut text)?, ++ }) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use crate::{dtype::Datatype, fspec::ParameterPieces}; ++ use kuna_base::{ ++ address::Address, ++ space::{spacetype, AddrSpace}, ++ }; ++ use std::rc::Rc; ++ #[test] ++ fn absent_store_is_unknown_not_an_empty_signature() { ++ assert!(collect(&FuncProto::new()) ++ .unwrap_err() ++ .contains("unavailable")); ++ } ++ #[test] ++ fn native_slots_return_storage_void_and_locks_remain_distinct() { ++ let mut proto = FuncProto::new(); ++ proto.attach_internal_store(Rc::new(Datatype::new(0, type_metatype::TYPE_VOID))); ++ let empty = collect(&proto).unwrap(); ++ assert!(empty.items.is_empty()); ++ assert_eq!(empty.output.is_void, Some(true)); ++ assert!(empty.output.storage.is_none()); ++ let reg = Rc::new(AddrSpace::new( ++ spacetype::IPTR_PROCESSOR, ++ "register", ++ false, ++ 8, ++ 1, ++ 3, ++ 0, ++ 0, ++ 0, ++ )); ++ let pieces = ParameterPieces { ++ addr: Address::new(reg, 0x38), ++ type_: Some(Rc::new(Datatype::new(8, type_metatype::TYPE_UNKNOWN))), ++ flags: 0, ++ }; ++ proto.set_param(0, "display name", &pieces); ++ proto.set_input_lock(true); ++ proto.set_output(&pieces); ++ let facts = collect(&proto).unwrap(); ++ assert_eq!(facts.items.len(), 1); ++ assert_eq!(facts.items[0].id, "parameter:0"); ++ assert_eq!(facts.items[0].native_index, Some(0)); ++ assert!(facts.inputs_locked && facts.items[0].type_locked); ++ assert_eq!(facts.output.is_void, Some(false)); ++ let storage = facts.output.storage.unwrap(); ++ assert_eq!(storage.location.space, "register"); ++ assert_eq!(storage.location.offset, "0x38"); ++ assert_eq!(storage.size_bytes, 8); ++ } ++} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_projection.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_projection.rs new file mode 100644 index 00000000..097b153a @@ -2027,6 +2205,42 @@ index 00000000..097b153a + }, + }) +} +diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_storage.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_storage.rs +new file mode 100644 +index 00000000..9d64d4c2 +--- /dev/null ++++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_storage.rs +@@ -0,0 +1,30 @@ ++//! Shared native storage representation; ABI/frame interpretation is not inferred. ++use kuna_base::address::Address; ++use serde::Serialize; ++ ++#[derive(Debug, Clone, Serialize)] ++pub struct Location { ++ pub space: String, ++ pub offset: String, ++} ++#[derive(Debug, Clone, Serialize)] ++pub struct Storage { ++ #[serde(flatten)] ++ pub location: Location, ++ pub size_bytes: i32, ++} ++pub(crate) fn location(address: &Address) -> Option { ++ address.get_space().map(|space| Location { ++ space: space.get_name().to_string(), ++ offset: format!("0x{:x}", address.get_offset()), ++ }) ++} ++pub(crate) fn storage(address: &Address, size_bytes: i32) -> Option { ++ if size_bytes <= 0 { ++ return None; ++ } ++ location(address).map(|location| Storage { ++ location, ++ size_bytes, ++ }) ++} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_switches.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_switches.rs new file mode 100644 index 00000000..e8992d5d @@ -2331,14 +2545,16 @@ index 39927ceb..844950a6 100644 let num_input = fd.obank().get(op).ok_or_else(|| "render_op: stale op".to_string())?.num_input(); let in_at = |i: int4| -> Option { fd.obank().get(op).and_then(|o| o.get_in(i)) }; diff --git a/decompiler/crates/kuna-decomp/src/substrate/mod.rs b/decompiler/crates/kuna-decomp/src/substrate/mod.rs -index 00528cb5..d3f422ee 100644 +index 00528cb5..4330ff1c 100644 --- a/decompiler/crates/kuna-decomp/src/substrate/mod.rs +++ b/decompiler/crates/kuna-decomp/src/substrate/mod.rs -@@ -11,6 +11,13 @@ pub mod funcdata_block; +@@ -11,6 +11,15 @@ pub mod funcdata_block; pub mod funcdata_encode; pub mod funcdata_op; pub mod funcdata_printraw; +pub mod atomic_facts; ++pub mod atomic_parameters; ++pub mod atomic_storage; +pub mod atomic_calls; +pub mod atomic_membership; +pub mod atomic_operations; diff --git a/experiments/atomic-codemode/native-calls.mjs b/experiments/atomic-codemode/native-calls.mjs index 5909724a05..40c80f2f6d 100644 --- a/experiments/atomic-codemode/native-calls.mjs +++ b/experiments/atomic-codemode/native-calls.mjs @@ -2,10 +2,7 @@ import { z } from "zod"; import { hex, ordinal, valueId, operationId } from "./native-contract.mjs"; -const location = z.strictObject({ space: z.string().min(1), offset: hex }); -const storage = location.extend({ - size_bytes: z.number().int().positive().max(0x7fffffff), -}); +import { nativeLocation, nativeStorage } from "./native-storage.mjs"; export const nativeCall = z.strictObject({ id: operationId, origin_address: hex, @@ -14,13 +11,13 @@ export const nativeCall = z.strictObject({ is_dead: z.boolean(), target_value_id: valueId.nullable(), spec_available: z.boolean(), - target: location.nullable(), + target: nativeLocation.nullable(), target_name: z.string().min(1).nullable(), arguments: z.array( z.strictObject({ input_index: z.number().int().positive().max(0x7fffffff), value_id: valueId.nullable(), - storage: storage.nullable(), + storage: nativeStorage.nullable(), storage_authority: z .enum(["final-input-storage", "locked-prototype"]) .nullable(), diff --git a/experiments/atomic-codemode/native-ir-contract.mjs b/experiments/atomic-codemode/native-ir-contract.mjs index bca6975d86..ce0a4dc31f 100644 --- a/experiments/atomic-codemode/native-ir-contract.mjs +++ b/experiments/atomic-codemode/native-ir-contract.mjs @@ -4,6 +4,7 @@ import { FactFailure } from "./fact-error.mjs"; import { NATIVE_OUTPUT_BYTES } from "./limits.mjs"; import { MAX_GRAPH_BLOCKS } from "./graph-limits.mjs"; import { readCallFacet } from "./native-call-contract.mjs"; +import { readParameterFacet } from "./native-parameters.mjs"; import { readProjectionFacet } from "./native-projection-contract.mjs"; import { readSwitchFacet } from "./native-switch-contract.mjs"; import { operation, parseOperation } from "./native-operation-contract.mjs"; @@ -71,6 +72,8 @@ const documentSchema = z.object({ atomic_facts_error: z.string().optional(), atomic_calls: z.unknown().optional(), atomic_calls_error: z.string().optional(), + atomic_parameters: z.unknown().optional(), + atomic_parameters_error: z.string().optional(), atomic_switches: z.unknown().optional(), atomic_switches_error: z.string().optional(), atomic_projection: z.unknown().optional(), @@ -220,6 +223,7 @@ export function parseNativeFunction(text, entry, expectedInput) { language: doc.language, facts: readGraph(fn), calls: readCallFacet(fn), + parameters: readParameterFacet(fn), switches: readSwitchFacet(fn), projection: readProjectionFacet(fn), }; diff --git a/experiments/atomic-codemode/native-ir.mjs b/experiments/atomic-codemode/native-ir.mjs index 64c5683103..0b1bedeb28 100644 --- a/experiments/atomic-codemode/native-ir.mjs +++ b/experiments/atomic-codemode/native-ir.mjs @@ -10,6 +10,7 @@ import { import { parseNativeFunction } from "./native-ir-contract.mjs"; import { createControlDependenceIndex } from "./control-dependence.mjs"; import { createGraphPathReader } from "./cfg-path.mjs"; +import { createParameterViews } from "./native-parameters.mjs"; import { freezeFacts as freeze } from "./fact-reads.mjs"; import { indexCallFacet, @@ -200,9 +201,14 @@ const captureFacets = ({ callError, switches, switchError, + parameters, + parameterError, }) => freeze({ code: { availability: "available" }, + parameters: parameters + ? { availability: "available", count: parameters.count } + : { availability: "unavailable", error: parameterError }, recovered_ir: graph ? { availability: "available" } : { availability: "unavailable", error: graphError }, @@ -270,6 +276,9 @@ const unavailable = }; export function parseNativeIR(text, entry, expectedInput) { const capture = parseNativeFunction(text, entry, expectedInput); + const parameters = loadFacet(capture.parameters, (facts) => ({ + view: createParameterViews(facts), + })); const graph = loadFacet(capture.facts, (facts) => { freeze(facts); const index = indexNative(facts); @@ -337,6 +346,9 @@ export function parseNativeIR(text, entry, expectedInput) { return Object.freeze({ ...(graph.view ?? projection.view ?? fallback), path: graph.view?.path ?? unavailableGraph, + parameters: + parameters.view?.parameters ?? + unavailable("parameters", parameters.error), edges: (input) => input.kind === "cases" ? (switches.view?.cases ?? unavailableSwitches)(input) @@ -358,6 +370,8 @@ export function parseNativeIR(text, entry, expectedInput) { callError: calls.error, switches: switches.view, switchError: switches.error, + parameters: parameters.view, + parameterError: parameters.error, }), }); } diff --git a/experiments/atomic-codemode/native-parameters.mjs b/experiments/atomic-codemode/native-parameters.mjs new file mode 100644 index 0000000000..99c1ccf88c --- /dev/null +++ b/experiments/atomic-codemode/native-parameters.mjs @@ -0,0 +1,112 @@ +// One format owner for recovered formal prototype entries, not raw input flags. +import { z } from "zod"; +import { nativeStorage } from "./native-storage.mjs"; +import { rowParser, readNativeFacet, requireFact } from "./native-contract.mjs"; +import { freezeFacts, projectFact } from "./fact-reads.mjs"; +import { nativePage } from "./native-reads.mjs"; + +const parameter = z.strictObject({ + id: z.string(), + native_index: z.number().int().nonnegative().nullable(), + name: z.string(), + type_name: z.string().nullable(), + size_bytes: z.number().int().min(-0x80000000).max(0x7fffffff), + is_void: z.boolean().nullable(), + storage: nativeStorage.nullable(), + type_locked: z.boolean(), + name_locked: z.boolean(), + size_locked: z.boolean(), + this_pointer: z.boolean(), + indirect_storage: z.boolean(), + hidden_return: z.boolean(), +}); +const schema = z.strictObject({ + schema_version: z.literal(1), + model: z.string().nullable(), + model_unknown: z.boolean().nullable(), + inputs_locked: z.boolean(), + output_locked: z.boolean(), + variadic: z.boolean(), + input_errors: z.boolean(), + output_errors: z.boolean(), + items: z.array(parameter).max(12000), + output: parameter, +}); +const parse = rowParser(schema, { items: (row) => parameter.parse(row) }); +const validate = (row, index) => { + requireFact( + row.native_index === index && + row.id === (index === null ? "return" : `parameter:${index}`), + "Native parameter identity/index mismatch", + ); + requireFact( + row.storage === null || row.storage.size_bytes === row.size_bytes, + "Native parameter storage/width mismatch", + ); + requireFact( + (row.type_name === null) === (row.is_void === null), + "Native parameter type/void knowledge mismatch", + ); + requireFact( + row.is_void !== true || (row.size_bytes === 0 && row.storage === null), + "Native void parameter has invented storage", + ); +}; +export const readParameterFacet = (fn) => + readNativeFacet(fn, "atomic_parameters", "function parameters", (raw) => { + const facts = parse(raw); + facts.items.forEach(validate); + validate(facts.output, null); + requireFact( + (facts.model === null) === (facts.model_unknown === null), + "Native prototype model knowledge mismatch", + ); + return facts; + }); +const defaultFields = [ + "storage", + "size_bytes", + "is_void", + "type_locked", + "indirect_storage", + "hidden_return", + "this_pointer", +]; +const identity = ["id", "native_index"]; +export function createParameterViews(facts) { + freezeFacts(facts); + const { schema_version: _version, items, output, ...prototype } = facts; + freezeFacts(prototype); + const coverage = freezeFacts({ + scope: "all entries in the captured native recovered function prototype", + source_signature: "unknown", + source_parameter_ordinals: "unknown", + raw_input_values: + "is_input is not formal-parameter membership; globals/stack may also be input varnodes", + ssa_binding: + "not collected here; exact selected storage/width lookup is separate and does not prove aliases", + runtime_binding: "unknown", + machine_semantic_equivalence: "unknown", + input_recovery_errors: prototype.input_errors, + output_recovery_errors: prototype.output_errors, + }); + return { + count: items.length, + parameters: (input) => { + const indices = + input.indices === undefined ? null : [...new Set(input.indices)]; + const rows = indices + ? indices.flatMap((index) => items[index] ?? []) + : items; + const fields = input.fields ?? defaultFields; + return { + ...nativePage(rows, { ...input, fields }, identity), + missing_indices: + indices?.filter((index) => index >= items.length) ?? [], + output: projectFact(output, fields, identity), + prototype, + coverage, + }; + }, + }; +} diff --git a/experiments/atomic-codemode/native-parameters.test.mjs b/experiments/atomic-codemode/native-parameters.test.mjs new file mode 100644 index 0000000000..7a0f84b680 --- /dev/null +++ b/experiments/atomic-codemode/native-parameters.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + readParameterFacet, + createParameterViews, +} from "./native-parameters.mjs"; +import { parseNativeIR } from "./native-ir.mjs"; +import { methods, reference } from "./api.mjs"; +import Ajv2020 from "ajv/dist/2020.js"; + +const parameter = ( + index, + storage = { space: "register", offset: "0x38", size_bytes: 8 }, +) => ({ + id: index === null ? "return" : `parameter:${index}`, + native_index: index, + name: "display", + type_name: "unknown8", + size_bytes: 8, + is_void: false, + storage, + type_locked: false, + name_locked: false, + size_locked: false, + this_pointer: false, + indirect_storage: false, + hidden_return: false, +}); +const fixture = () => ({ + schema_version: 1, + model: "__stdcall", + model_unknown: false, + inputs_locked: false, + output_locked: false, + variadic: false, + input_errors: false, + output_errors: true, + items: [ + parameter(0), + parameter(1, { space: "stack", offset: "0x8", size_bytes: 8 }), + ], + output: parameter(null, { space: "register", offset: "0x0", size_bytes: 8 }), +}); +const read = (raw) => readParameterFacet({ atomic_parameters: raw }); +const model = (fn) => + parseNativeIR( + JSON.stringify({ + binary: "/input", + count: 1, + language: "c", + error: null, + functions: [ + { address_hex: "0x1000", error: null, code: "readable", ...fn }, + ], + }), + "0x1000", + ); + +test("formal selection preserves native indices, caller order, unknowns and independently selected output", () => { + const parsed = read(fixture()); + assert.equal(parsed.ok, true); + const view = createParameterViews(parsed.value); + const first = view.parameters({ + indices: [1, 0, 1, 8], + fields: ["storage", "size_bytes"], + limit: 1, + }); + assert.deepEqual( + first.items.map((r) => r.id), + ["parameter:1"], + ); + assert.equal(first.total, 2); + assert.equal(first.next_offset, 1); + assert.deepEqual(first.missing_indices, [8]); + assert.equal(first.output.id, "return"); + assert.equal(first.prototype.output_errors, true); + assert.equal(first.coverage.source_signature, "unknown"); + const next = view.parameters({ indices: [1, 0], fields: [], offset: 1 }); + assert.deepEqual(next.items, [{ id: "parameter:0", native_index: 0 }]); + assert.deepEqual(next.output, { id: "return", native_index: null }); + assert.deepEqual(view.parameters({ indices: [] }).items, []); + assert.equal(Object.hasOwn(view.parameters({}).items[0], "type_name"), false); +}); +test("cached metadata/storage cannot be changed by SDK callers", () => { + const view = createParameterViews(read(fixture()).value); + const result = view.parameters({}); + assert.throws(() => { + result.prototype.model = "changed"; + }, TypeError); + assert.throws(() => { + result.items[0].storage.offset = "0x0"; + }, TypeError); + result.items.length = 0; + assert.equal(view.parameters({}).total, 2); + assert.equal(view.parameters({}).prototype.model, "__stdcall"); +}); +test("unknown type/storage is distinct from native void, malformed identities cannot publish partial formals", () => { + const unknown = fixture(); + Object.assign(unknown.output, { + type_name: null, + is_void: null, + storage: null, + }); + assert.equal(read(unknown).ok, true); + const empty = fixture(); + empty.items = []; + Object.assign(empty.output, { + type_name: "void", + is_void: true, + size_bytes: 0, + storage: null, + }); + assert.equal(read(empty).ok, true); + for (const change of [ + (x) => (x.items[0].id = "parameter:9"), + (x) => (x.items[1].native_index = 0), + (x) => (x.output.native_index = 0), + (x) => (x.items[0].size_bytes = 4), + (x) => (x.output.is_void = true), + (x) => (x.output.type_name = null), + (x) => (x.model_unknown = null), + ]) { + const x = fixture(); + change(x); + assert.equal(read(x).ok, false); + } +}); +test("prototype facet works without graph/calls; unavailable/invalid prototype retains code and reason", () => { + const available = model({ + atomic_parameters: fixture(), + atomic_facts_error: "capacity", + atomic_calls_error: "capacity", + }); + assert.equal(available.facets.parameters.availability, "available"); + assert.equal(available.parameters({}).total, 2); + assert.equal(available.facets.recovered_ir.availability, "unavailable"); + assert.equal(available.code, "readable"); + for (const fn of [ + {}, + { + atomic_parameters_error: "native function prototype store is unavailable", + }, + { atomic_parameters: { schema_version: 99 } }, + ]) { + const unavailable = model(fn); + assert.equal(unavailable.code, "readable"); + assert.equal(unavailable.facets.parameters.availability, "unavailable"); + assert.throws( + () => unavailable.parameters({}), + (e) => e.details.facet === "parameters", + ); + } +}); +test("parameter input schema matches advertised JSON Schema and rejects guessed keys/indices", () => { + const validate = new Ajv2020({ strict: false }).compile( + reference("parameters").methods[0].input_schema, + ); + for (const [request, valid] of [ + [{}, true], + [{ indices: [], fields: [] }, true], + [{ indices: [1, 0, 1], fields: ["storage", "type_name"] }, true], + [{ indices: [-1] }, false], + [{ indices: ["0"] }, false], + [{ fields: ["value_id"] }, false], + [{ signature: true }, false], + ]) { + const input = { snapshot: "snap_test", ...request }; + assert.equal(methods.parameters.input.safeParse(input).success, valid); + assert.equal(validate(input), valid); + } +}); diff --git a/experiments/atomic-codemode/native-storage.mjs b/experiments/atomic-codemode/native-storage.mjs new file mode 100644 index 0000000000..e41217d55d --- /dev/null +++ b/experiments/atomic-codemode/native-storage.mjs @@ -0,0 +1,10 @@ +// Shared native address/storage syntax; caller/callee interpretation stays separate. +import { z } from "zod"; +import { hex } from "./native-contract.mjs"; +export const nativeLocation = z.strictObject({ + space: z.string().min(1), + offset: hex, +}); +export const nativeStorage = nativeLocation.extend({ + size_bytes: z.number().int().positive().max(0x7fffffff), +}); diff --git a/experiments/atomic-codemode/provider.mjs b/experiments/atomic-codemode/provider.mjs index 4f95c4228d..ace16251ac 100644 --- a/experiments/atomic-codemode/provider.mjs +++ b/experiments/atomic-codemode/provider.mjs @@ -292,6 +292,7 @@ export class KunaFactOwner { availability: "available", scope: "diagnostic-printed-SSA", }, + parameters: missingNativeFacet(), calls: missingNativeFacet(), values: missingNativeFacet(), }, @@ -421,32 +422,34 @@ export class KunaFactOwner { : {}, ); } - async values(input) { + #queryNative(input, read) { this.#requireNativeIR(input.snapshot); - return this.#query(input, (model) => model.values(input)); + return this.#query(input, read); + } + async values(input) { + return this.#queryNative(input, (model) => model.values(input)); } async calls(input) { - this.#requireNativeIR(input.snapshot); - return this.#query(input, (model) => model.calls(input)); + return this.#queryNative(input, (model) => model.calls(input)); } async switches(input) { - this.#requireNativeIR(input.snapshot); - return this.#query(input, (model) => model.switches(input)); + return this.#queryNative(input, (model) => model.switches(input)); } async blocks(input) { - this.#requireNativeIR(input.snapshot); - return this.#query(input, (model) => ({ + return this.#queryNative(input, (model) => ({ ...model.blocks(input), coverage: model.coverage, })); } async path(input) { - this.#requireNativeIR(input.snapshot); - return this.#query(input, (model) => ({ + return this.#queryNative(input, (model) => ({ ...model.path(input), coverage: model.coverage, })); } + async parameters(input) { + return this.#queryNative(input, (model) => model.parameters(input)); + } async code(input) { const descriptor = this.#snapshots.get(input.snapshot); if (!descriptor) diff --git a/experiments/atomic-codemode/reference.mjs b/experiments/atomic-codemode/reference.mjs index 5ba7866546..1b15edb8ee 100644 --- a/experiments/atomic-codemode/reference.mjs +++ b/experiments/atomic-codemode/reference.mjs @@ -24,7 +24,7 @@ const returns = { references: "data:{results:[{ok,address,source:{evidence_id},items:[{source_row,from,to,kind,fromFunction:{address,name}|null,toFunction:{address,name}|null,instruction}],total,next_offset,coverage}|{ok:false,address,error}]}. source_row is the zero-based row in retained native xrefs; Evidence identity plus source_row identifies an occurrence. Coverage preserves examined/excluded/unattributed counts and unknown function scope/discovery. fields projects optional columns; endpoints/kind/source_row stay.", snapshot: - "data:{snapshot:string,entry,source:{evidence_id,sha256},facets:{code:{availability},recovered_ir:{availability,error?},operations:{availability,scope?,count?,root_count?,definition_frontier_count?,error?},calls:{availability,count?,by_opcode?:[{opcode,known_target_count,unknown_target_count}],coverage?,error?},values:{availability,scope?,count?,error?},switches?:{availability,count?,table_count?,partial_table_count?,case_count?,relation_count?,error?}},coverage}. Call counts cover all retained recovered calls, including is_dead rows, not complete machine/runtime calls; target knowledge comes from a non-null native target, independent of opcode. An unavailable facet omits counts; an available empty call facet reports zeros. The complete graph can be unavailable while operations reports a bounded projection count; an unavailable operation facet has unknown/null counts, never a fabricated empty set; code remains readable through the same handle.", + "data:{snapshot:string,entry,source:{evidence_id,sha256},facets:{code:{availability},parameters?:{availability,count?,error?},recovered_ir:{availability,error?},operations:{availability,scope?,count?,root_count?,definition_frontier_count?,error?},calls:{availability,count?,by_opcode?:[{opcode,known_target_count,unknown_target_count}],coverage?,error?},values:{availability,scope?,count?,error?},switches?:{availability,count?,table_count?,partial_table_count?,case_count?,relation_count?,error?}},coverage}. Call counts cover all retained recovered calls, including is_dead rows, not complete machine/runtime calls; target knowledge comes from a non-null native target, independent of opcode. An unavailable facet omits counts; an available empty call facet reports zeros. The complete graph can be unavailable while operations reports a bounded projection count; an unavailable operation facet has unknown/null counts, never a fabricated empty set; code remains readable through the same handle.", operations: "data:{items:[{id,origin_address,block,is_dead,kind,opcode,output,input_count,definition_count,constant_count}],total,next_offset,evidence_id,coverage}. Native defaults are compact summaries; fields optionally selects statement,inputs,definitions,constants or summary columns; identity/is_dead stay. Native inputs:[{input_index,value_id,phi_predecessor,phi_predecessor_edge}], output:value_id|null; constants:[{value_id,input_index,value,printed_size_bytes,is_annotation}] retain exact u64 offsets, not arbitrary-width computed values; definitions:[{definition,use,input_index,value_id,phi_predecessor,phi_predecessor_edge}]. Page large input lists with relations kind:operands. Diagnostic rows retain legacy printed statements/occurrences and omit unavailable native counts. Projection scope is partial, with unknown CFG and definition frontier coverage.", relations: @@ -36,6 +36,8 @@ const returns = { blocks: "data:{items:[{id,origin_start,operations,predecessors,successors:[{target,index,polarity,condition_value,condition_test}]}],total,next_offset,evidence_id,coverage}; IDs are local to this snapshot.", path: "data:{status:found|not-found|unknown,from,target:{kind:block,id}|{kind:exit},avoid,end:number|null,items:[native CFG edge with id,from,to,index,operation,polarity,condition_value,condition_test,step_index],total:number|null,next_offset,unretained_blocks,path_coverage:{scope,search,examined_blocks,examined_edges,branch_predicate_feasibility:unknown,machine_semantic_equivalence:unknown,runtime_reachability:unknown},evidence_id,coverage}. total is full witness edge count before paging (zero for found zero-edge/not-found, null for unknown selection); end identifies the terminal block even on an empty page. step_index is the path position; index remains native successor ordinal. Unknown selections are never cached as no-path; absence only covers recovered edges under exclusions. Edges retain source identities and the complete snapshot Evidence remains readable.", + parameters: + "data:{items:[{id:parameter:N,native_index:N,storage?,size_bytes?,is_void?,type_locked?,indirect_storage?,hidden_return?,this_pointer?,name?,type_name?,name_locked?,size_locked?}],output:{id:return,native_index:null,...same selected fields},prototype:{model,model_unknown,inputs_locked,output_locked,variadic,input_errors,output_errors},total,next_offset,missing_indices,coverage,evidence_id}. Entries are native recovered formals, not raw is_input varnodes or proven source ordinals. Exact source unknowns and lock/error flags remain. indices deduplicate in caller order; empty indices selects none but preserves output/prototype. total counts selected entries before paging, missing_indices reports absent native slots. Output storage absence alone is not void; is_void:null is unknown. No automatic inter-function SSA equality or stack/alias interpretation is asserted.", slice: "data:{operations:[operation rows as above],coverage:{status,boundaries:[{id,reason}],missing,missing_reasons?,source_scope?,source_issues,runtime_reachability,memory_alias_dependencies},value_frontier?:[{value_id?|operation?,input_index?,reason}],evidence_id}", code: "data:{evidence_id,start_line,language:string|null,text,total_lines,next_offset,coverage}; language is native-reported or unknown. Read/filter full reconstructed text within Code Mode without returning it all.", diff --git a/experiments/atomic-codemode/reference.test.mjs b/experiments/atomic-codemode/reference.test.mjs index ca7e31f5ed..9ce8a36c0d 100644 --- a/experiments/atomic-codemode/reference.test.mjs +++ b/experiments/atomic-codemode/reference.test.mjs @@ -35,7 +35,7 @@ test("reference index discovers named methods without constructing unrequested s /Unknown reference view/, ); const all = reference("", { view: "index" }); - assert.equal(all.methods.length, 16); + assert.equal(all.methods.length, 17); assert.ok( all.methods.some( (m) => m.method === "relations" && m.description.includes("operands"), @@ -75,7 +75,7 @@ test("named reference lists preserve caller order and exact schemas across prese ["facts_values", "facts_snapshot"], ); assert.deepEqual(result.unmatched_queries, ["unknown"]); - assert.equal(result.coverage.declared_methods, 16); + assert.equal(result.coverage.declared_methods, 17); assert.equal(result.coverage.matched_methods, 2); assert.equal(result.coverage.selection, "named"); const javascript = reference("rea.values snapshot"); @@ -104,7 +104,7 @@ test("named reference lists preserve caller order and exact schemas across prese }); test("topic/all reference selection and shared schema ownership remain explicit", () => { - assert.equal(reference().methods.length, 16); + assert.equal(reference().methods.length, 17); assert.equal(reference("no-such-topic").coverage.matched_methods, 0); assert.ok( reference("memory aliases").methods.some((m) => m.method === "slice"), diff --git a/experiments/atomic-codemode/verify-discovery.mjs b/experiments/atomic-codemode/verify-discovery.mjs index 4f7a9df4f8..611bdcb229 100644 --- a/experiments/atomic-codemode/verify-discovery.mjs +++ b/experiments/atomic-codemode/verify-discovery.mjs @@ -53,6 +53,7 @@ for (const direct of [false, true]) { const ids = [ "switches", "path", + "parameters", "snapshot", "operations", "relations", @@ -67,7 +68,7 @@ for (const direct of [false, true]) { ids, ); assert.deepEqual(selected.unmatched_queries, []); - assert.equal(selected.coverage.declared_methods, 16); + assert.equal(selected.coverage.declared_methods, 17); const ajv = new Ajv2020({ strict: false }); for (const tool of catalog.tools) ajv.compile(tool.inputSchema); assert.equal(Object.hasOwn(selected, "limits"), !direct); @@ -169,6 +170,15 @@ for (const direct of [false, true]) { input_indices: [0], }, ], + [ + "parameters", + { + snapshot: "snap_other", + indices: [0, 1], + fields: ["storage", "is_void"], + }, + { snapshot: "snap_other", indices: ["0"] }, + ], [ "evidence", { evidence_id: "ev_example", component: "recovered_ir" }, @@ -206,7 +216,7 @@ for (const direct of [false, true]) { ); assert.deepEqual(missing.unmatched_queries, ["unknown"]); const all = await call(""); - assert.equal(all.methods.length, 16); + assert.equal(all.methods.length, 17); for (const method of all.methods) { ajv.compile(method.input_schema); assert.equal(method.description, methods[method.method].description); diff --git a/experiments/atomic-codemode/verify-parameters.mjs b/experiments/atomic-codemode/verify-parameters.mjs new file mode 100644 index 0000000000..4a22a6a84c --- /dev/null +++ b/experiments/atomic-codemode/verify-parameters.mjs @@ -0,0 +1,208 @@ +// Real public boundary facts; the complete old record remains a fidelity oracle. +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { familyCounters } from "./family-counters.mjs"; +import { + createVerifierRun, + completeVerifierRun, + assertVerifierCleanupComplete, +} from "../../scripts/lib/verifier-run.mjs"; + +const [output] = process.argv.slice(2); +assert.ok(output && process.env.REA_ATOMIC_EVIDENCE_DIR); +const cases = [ + [ + "0x25cb0", + "ev_36a9ed8af203f2f64ca1a60d51b791f3c52e81fc1aa0e895aa183d99fd3ceba9", + ], + [ + "0x280c0", + "ev_007ef9474700eff262536cfc0ff6e0187afaf50990888cc446bc78fa58538f65", + ], +]; +const fields = [ + "name", + "type_name", + "storage", + "size_bytes", + "is_void", + "type_locked", + "name_locked", + "size_locked", + "this_pointer", + "indirect_storage", + "hidden_return", +]; +const run = createVerifierRun(), + calls = [], + phases = [], + reviews = [], + failures = []; +const direct = process.env.REA_ATOMIC_ADAPTER === "direct"; +const client = new Client({ name: "rea-parameter-verifier", version: "0.1.0" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("experiments/atomic-codemode/server.mjs")], + env: { ...process.env }, + stderr: "pipe", +}); +const call = async (name, input) => { + const reply = await client.callTool( + { name, arguments: input }, + { timeout: 180000 }, + ); + const result = JSON.parse(reply.content[0].text); + calls.push({ + name, + input, + result, + response_bytes: Buffer.byteLength(reply.content[0].text), + }); + assert.equal(result.ok, true, JSON.stringify(result)); + return result; +}; +const read = async (name, input) => { + const result = direct + ? await call(`facts_${name}`, input) + : ( + await call("execute_javascript", { + code: `return await rea.${name}(${JSON.stringify(input)});`, + }) + ).value; + assert.equal(result.ok, true, JSON.stringify(result)); + return result.data; +}; +const measure = async (phase, work) => { + const before = await familyCounters(), + start = performance.now(), + first = calls.length; + const result = await work(), + after = await familyCounters(); + phases.push({ + phase, + elapsed_ms: performance.now() - start, + family_cpu_seconds: (after.cpu - before.cpu) / 1e6, + cumulative_family_memory_peak_bytes: after.peak, + mcp_calls: calls.length - first, + response_bytes: calls + .slice(first) + .reduce((sum, c) => sum + c.response_bytes, 0), + }); + return result; +}; +try { + await client.connect(transport); + await call("open_binary", { path: "/usr/bin/ssh" }); + for (const [entry, prior] of cases) { + const capture = await measure(`capture:${entry}`, () => + read("snapshot", { entry }), + ); + assert.equal( + capture.facets.parameters.availability, + "available", + JSON.stringify(capture), + ); + const snapshot = capture.snapshot; + const full = await measure(`parameters-first:${entry}`, () => + read("parameters", { snapshot, fields, limit: 128 }), + ); + const repeat = await measure(`parameters-repeat:${entry}`, () => + read("parameters", { snapshot, fields, limit: 128 }), + ); + assert.deepEqual(repeat, full); + assert.equal(full.next_offset, null); + assert.equal(full.items.length, capture.facets.parameters.count); + assert.equal(full.coverage.source_signature, "unknown"); + const original = JSON.parse( + await readFile( + join( + process.env.REA_ATOMIC_EVIDENCE_DIR, + `${capture.source.evidence_id}.txt`, + ), + "utf8", + ), + ).functions[0]; + const { atomic_parameters: raw, ...ordinary } = original; + const golden = JSON.parse( + await readFile( + resolve(".cache/atomic-codemode/ssh-guard-v1-evidence", `${prior}.txt`), + "utf8", + ), + ).functions[0]; + assert.deepEqual(ordinary, golden); + assert.deepEqual(full.items, raw.items); + assert.deepEqual(full.output, raw.output); + const { + schema_version: _version, + items: _items, + output: _output, + ...prototype + } = raw; + assert.deepEqual(full.prototype, prototype); + const selected = await read("parameters", { + snapshot, + indices: [raw.items.length - 1, 0, 0, raw.items.length + 1], + fields: [], + limit: 1, + }); + assert.equal(selected.total, Math.min(raw.items.length, 2)); + assert.deepEqual(selected.missing_indices, [raw.items.length + 1]); + assert.deepEqual(selected.output, { id: "return", native_index: null }); + const empty = await read("parameters", { + snapshot, + indices: [], + fields: ["storage", "is_void"], + }); + assert.deepEqual(empty.items, []); + const rawInputs = await read("values", { + snapshot, + is_input: true, + limit: 1, + }); + const locations = raw.items.flatMap((p) => (p.storage ? [p.storage] : [])); + const matches = await read("values", { + snapshot, + is_input: true, + locations, + limit: 128, + }); + reviews.push({ + entry, + source: capture.source, + prototype: full.prototype, + items: full.items, + output: full.output, + raw_input_value_count: rawInputs.total, + formal_storage_input_matches: matches.items, + unretained_formal_locations: matches.unretained_locations, + complete_previous_function_equal_except_added_parameters: true, + }); + } + await call("close_binary", {}); +} catch (error) { + failures.push(error.stack ?? String(error)); +} finally { + await client.close(); + await transport.close(); +} +const lifecycle = await completeVerifierRun(run); +assertVerifierCleanupComplete(lifecycle.process_lineage); +await writeFile( + output, + JSON.stringify({ + direct, + phases, + calls, + reviews, + failures, + lifecycle, + scope: + "Actual SDK MCP and complete retained native fidelity; recovered prototypes are not source signatures or ABI/runtime proof. No model cost claim.", + }), + { flag: "wx" }, +); +console.log(JSON.stringify({ output, direct, phases, reviews, failures })); +if (failures.length) process.exitCode = 1;