diff --git a/docs/adr/0004-windows-native-authority-packaging.md b/docs/adr/0004-windows-native-authority-packaging.md new file mode 100644 index 000000000..cb8ccd222 --- /dev/null +++ b/docs/adr/0004-windows-native-authority-packaging.md @@ -0,0 +1,531 @@ +# ADR-0004: Windows native authority packaging + +- Status: Accepted +- Date: 2026-07-17 +- Implementation status: the provider-neutral TypeScript base port and + fail-closed host capability are implemented; the platform-package shape and + protected runtime-projection type state are selected and implemented. The + fail-closed package loader exists but is disabled by a null release artifact + commitment; the platform package, native implementation, release integration, + and real-Windows proof do not exist, so all Windows controls remain unavailable + +## Context + +REA's experimental Windows x64 Ghidra boundary cannot establish three security +properties with Node's public filesystem and child-process APIs alone: + +- atomic process creation inside a dedicated kill-on-close Job Object; +- handle-based path admission that rejects disallowed reparse points and retains + stable file identity; and +- creation and readback verification of protected private DACLs. + +The existing fallback uses bounded `taskkill /T /F`, pathname-based Node file +operations, and POSIX mode requests. These are useful P0 mechanics, but they are +not evidence for the stronger Windows authority claims. + +REA installation must remain compiler-free. Setup may not install Visual +Studio Build Tools, Python, node-gyp, PowerShell modules, or another runtime. +The packed CLI is also verified with lifecycle scripts disabled, so native +support cannot depend on an install hook. + +## Decision drivers + +1. Keep Win32 handles, structures, constants, and calling conventions outside + domain, application, Ghidra, CLI, and MCP contracts. +2. Prevent target or launcher code from running before authority is established. +3. Work from an npm installation without a local compiler or install script. +4. Fail closed with typed capability diagnostics when the exact native artifact + is absent, unsupported, unreadable, or incompatible. +5. Preserve the current limited P0 behavior without allowing it to satisfy a + native-authority capability claim. +6. Verify the published artifact and the real Windows behavior independently. + +## Options considered + +### Source-built Node-API addon + +Rejected. It requires a compiler toolchain during installation or setup and +would make availability depend on mutable operator build state. + +### Source-owned helper executable + +Rejected. A helper adds an authenticated protocol, helper-process ownership, +version negotiation, output capture, and crash cleanup without improving the +executable identity binding: it must still call `CreateProcessW` with a path. +The addon can perform blocking Win32 waits and pipe reads on source-owned native +threads and cross into JavaScript only through public Node-API operations. + +### Prebuilt Windows Node-API addon in a platform package + +Selected. Node-API provides an ABI-stable interface for the +handle and DACL operations when the addon uses only Node-API/node-addon-api +rather than V8, libuv, or Node internals. Release CI builds the Windows x64 +artifacts. A platform-specific npm package declares +`os: ["win32"]` and `cpu: ["x64"]`; the main package references its exact +version as an optional dependency. Neither package has an install script or a +source-build fallback, and the platform package contains no helper executable. + +This follows the platform-package selection pattern used by mature native npm +packages while deliberately omitting their node-gyp fallback. It also fits +REA's existing package verifier, which exercises installs with scripts disabled +and with optional dependencies omitted. + +## Boundary + +The TypeScript side exposes one provider-neutral external-adapter port with +three cohesive capabilities: + +```text +ProviderProcess / PrivateRuntimeRoot / authorized file operations + | + WindowsNativeAuthority port + / | \ + owned process admitted handle sealed runtime + lifetime and identity projection + | + optional Windows x64 Node-API adapter package +``` + +The port returns typed observations and failures. It never returns raw Win32 +handles, pointers, SIDs, access masks, or platform DTOs. Resource values own an +explicit idempotent `close` operation; the composition root also registers an +environment cleanup hook as crash/teardown defense, not as the normal lifetime +API. + +The TypeScript port is an application-owned interface. Its operations are +limited to: + +- `admitPath`, returning an opaque, closeable admitted-file identity plus + normalized observations needed for policy decisions; +- `createPrivateRoot`, consuming admitted source trees/files and returning an + opaque, closeable sealed projection with manifest, entry-point, launch, and + verified owner/access-policy observations; and +- `spawnOwnedProcess`, consuming only that sealed projection plus stdio limits + and an absolute startup deadline, and returning provider-neutral process I/O + and lifetime operations. Source paths, executable identities, arguments, and + environment values cannot be supplied or changed at this boundary. + +Each operation returns the existing `Result` shape with a dedicated tagged +failure union covering unavailable capability, invalid input, policy rejection, +identity drift, access denial, resource exhaustion, timeout, native contract +mismatch, and unexpected native failure. No native adapter may export platform +DTOs directly to its callers. + +The application-owned contract lives in +`src/application/WindowsNativeAuthority.ts`. It fixes the public names +`WindowsNativeAuthority`, `WindowsAdmittedFile`, +`WindowsAdmittedDirectory`, `WindowsPrivateRoot`, and +`WindowsOwnedProcess`. Module-owned wrappers register backend tokens in a +shared weak registry and reject resources created by another authority +instance, aliased by another backend result, or already closing/closed. File +and directory admission are distinct types, so a +directory cannot be supplied as an executable and a file cannot be supplied as +the parent of a private root. The backend must continuously drain process +output and retain no more than the committed byte limits; callers receive +detached snapshots with retained bytes, total byte counts, and truncation +flags. Process completion and close are typed results, and every native +resource owns an explicit idempotent close operation. The built-in unavailable +implementation fails all three operations closed and reports no loaded +identity; the Windows capability report continues to advertise no native +authority. + +Successful path admission is deliberately narrower than an opaque native +identity string. The adapter must return the exact requested path, the +handle-derived final diagnostic path, the admitted `ntfs` filesystem tag, a +lowercase 16-hex-digit volume identity, a lowercase 32-hex-digit file identity, +and the regular-file digest where applicable. The volume identity is the +zero-padded hexadecimal representation of the unsigned `FILE_ID_INFO` +`VolumeSerialNumber` numeric value. The file identity is the lowercase +hexadecimal encoding of `FileId.Identifier[0]` through +`FileId.Identifier[15]` in array order. The composite identity is scoped to the +retained handle and admitted volume; it is not a durable global identifier and +must not authorize a later reopened object. + +The application boundary parses the runtime-shaped report, rejects unknown or +malformed fields, derives one canonical string encoding, and closes the +returned backend resource before reporting a native-contract mismatch. This +proves only the shape and canonical encoding of what the adapter reports. The +trusted native implementation and real-Windows verifier must separately prove +that the filesystem tag and identity bytes came from handle-based filesystem +inspection and `GetFileInformationByHandleEx(FileIdInfo)`. TypeScript schema +validation cannot prove Win32 provenance or prevent a malicious native module +from inventing well-shaped values. This is contract hardening only: it does not +implement `CreateFileW` traversal or make the host capability available. + +The sealed-root observation applies the same boundary discipline to #290. A +native result is not published as a `WindowsPrivateRoot` until the application +parses a strict readback report for five object classes: the root directory, +read-only directories, read-only files, writable directories, and writable +files. Each class commits its object kind, explicit-versus-inherited ACE +disposition, propagation flags, verified object count, descriptor digest, and +path-to-policy manifest digest. One enclosing security manifest binds all five +classes to both the sealed projection manifest and launch commitment. Its +versioned canonical input contains `version`, `projectionManifestSha256`, +`launchSha256`, and the five ordered class records, each limited to +`verifiedObjectCount` and `policyManifestSha256`. + +Root and existing immutable objects require explicit protected DACLs. Directory +policies distinguish inheritable container/object ACEs from non-propagating +leaf-file policies. Writable directories require an explicit protected policy; +files created beneath them report inherited ACEs from that protected parent. +Every class must state current-user ownership, no additional trustee or broad +allow entry, SYSTEM full control, and an explicit OWNER RIGHTS denial of +`WRITE_DAC` and `WRITE_OWNER`. Root and immutable objects admit only +current-user read/execute; writable objects admit current-user modify. Null +DACLs, unexpected fields, different rights, missing OWNER RIGHTS policy, +class/propagation contradictions, count or manifest mismatches, or malformed +digests poison and close the returned native token. + +These normalized values deliberately omit raw SIDs, ACEs, masks, and security +descriptor buffers. Schema acceptance proves only that the adapter reported the +selected policy vocabulary. The native implementation must create the +descriptors, read them back from retained handles, canonicalize trustee and +rights semantics, and prove the report against real non-administrator Windows +access tests. Until that implementation and verifier exist, private-DACL +capability remains unavailable. + +The native package remains unimplemented. The owner boundary, packaging shape, +and launch binding are accepted; capability availability still depends on the +projection and complete native teardown state machine below passing the +real-Windows adversarial verifier. + +The initial admission contract accepts only `reparsePolicy: "reject_all"` and +can report only `reparseDisposition: "absent"`. A later admitted reparse kind +must be added as an explicit normalized policy variant together with its real +Windows proof; the generic port does not provide an open-ended allow mode. + +The three implementation slices remain separate: + +1. **Job Object ownership (#288):** create a dedicated Job Object, apply + kill-on-close without silent breakaway, and create the launcher already + assigned through the process-creation attribute list. +2. **Handle-based filesystem authority (#289):** open authority-relevant path + components without following reparse points, admit an explicit filesystem + and reparse-tag set, retain or revalidate stable identity, and perform + sensitive I/O through that authority. +3. **Private runtime DACLs (#290):** create the root with a protected DACL, + verify owner/trustees/rights/inheritance by handle, and make all runtime + children inherit or receive the verified policy. + +#289 owns the shared handle and filesystem-identity primitives used by #290 and +the eventual launch binding consumed by #288. Job Object implementation follows +#289 and #290; it cannot satisfy or advertise the Windows native authority +boundary until the executable, runtime root, and session token are all +protected. + +The delivery order is #289, then #290, then #288. This lets process creation +consume an already verified executable identity and private runtime root. The +P0 limitations remain in force until all three real-Windows gates pass. + +`CreateProcessW` cannot consume the admitted file handle directly. A retained +path-component chain opened without write/delete sharing was evaluated and +rejected as a complete binding. It prevents later conflicting opens, rename, +and ordinary replacement, but it cannot revoke a writable mapped view created +before admission. Such a view can mutate the admitted bytes after hashing while +the writer's file handle is already closed. + +Protecting only the primary executable is also insufficient for the Windows +Ghidra launch. The current launcher includes the command interpreter, a batch +script, Java, Ghidra JARs, native libraries, configuration, and DLL search +inputs. A share lock on `lpApplicationName` does not bind that runtime closure. + +The selected design projects the complete non-system runtime into the private +root before process creation. Immutable-install admission is rejected: a BYO +Ghidra or JDK tree normally belongs to the same analyst identity that runs REA, +so that identity can replace content or rewrite its ACL. Treating such an +installation as immutable would silently narrow the threat boundary. + +The projection contains: + +- the complete Ghidra installation root, including root + `application.properties`, the `Ghidra/` module tree, JARs, native libraries, + launch properties, and configuration discovered by `GhidraClassLoader`; +- the complete admitted JDK 21 root; +- the packaged REA Ghidra bridge and session descriptor; and +- the digest-bound target snapshot plus private project, home, temporary, + cache, configuration, and data directories. + +The native adapter enumerates each admitted tree relative to retained directory +handles, rejects every reparse point and unsupported filesystem, admits only +regular files and directories, and applies explicit entry, depth, and byte +limits. It copies each file through handles into a newly created destination, +hashes the bytes while copying, closes and reopens the destination by handle, +and verifies the readback digest. A canonical sorted manifest of relative path, +file size, and SHA-256 is hashed into one runtime-projection identity. The +readback projection, not the mutable source installation, is intended to become +the launch authority. Concurrent source mutation may make projection fail or +produce a different committed snapshot. + +Read-only projected files and directories use a protected DACL that grants the +analyst read/execute and SYSTEM full control while withholding create, write, +append, delete, `WRITE_DAC`, and `WRITE_OWNER`. Because a file owner otherwise +has implicit DACL-rewrite authority, the descriptor includes an explicit +`OWNER RIGHTS` SID entry that withholds those mutation rights. Writable runtime +subdirectories use a separate verified policy and never contain executable, +classpath, or native-library inputs. Creation and readback of both policies +belong to #290. + +Applying that restrictive descriptor only after population is rejected. An +access check occurs when a handle is opened; later DACL changes do not revoke a +same-user process's previously granted write, delete, mapping, or `WRITE_DAC` +rights. The native implementation must therefore apply the final descriptor at +object creation or establish another proven construction principal/capability, +retain only non-inheritable construction handles, close every mutation-capable +handle before commit, and retain only non-mutating authority/cleanup handles. +Whether the selected non-administrator Win32 creation APIs can populate the +tree under its final descriptor remains an explicit native design/proof gap. +Until a real-Windows race proves that pre-open write, delete, `WRITE_DAC`, +directory-create, and writable-section handles cannot survive commit, REA does +not claim same-user immutability for the projection. #290 can establish privacy +against unrelated local principals without silently satisfying that stronger +same-user launch-authority requirement. + +Windows launches the projected JDK directly, without `cmd.exe`, +`analyzeHeadless.bat`, `launch.bat`, `LaunchSupport`, or another pre-authority +process. REA derives and validates the exact Ghidra 12.1.2 foreground headless +arguments from the supported launch contract: + +```text +\jdk\bin\java.exe + -cp \ghidra\Ghidra\Framework\Utility\lib\Utility.jar + ghidra.Ghidra ghidra.app.util.headless.AnalyzeHeadless +``` + +`lpApplicationName`, the classpath, script path, target, project, user, cache, +and temporary paths all resolve beneath the sealed projection. The current +directory is the sealed Ghidra root. The environment removes ambient Java and +Ghidra option variables and admits only the projected JDK `bin` plus the +canonical System32 directory into `PATH`. Java native-library paths are +enumerated from the projection manifest. The process-creation mitigation policy +rejects remote and low-integrity image loads and prefers System32 for system +images. The verifier must prove the exact supported Windows build honors these +mitigations; there is no weaker DLL-search fallback. + +Trusted inputs outside the projection are limited to the Windows kernel, +KnownDLLs, and binaries or DLLs resolved from the canonical Windows/System32 +roots under the supported non-administrator Windows installation. REA records +those roots and the applied mitigation policy in the native launch observation. + +The implemented TypeScript port represents the projection lifecycle as type +state: + +1. a mutable private-root builder accepts admitted source trees/files and + bounded destination-relative names; +2. sealing returns an immutable `WindowsRuntimeProjection` observation with the + readback tree digest and an opaque projected entry point; and +3. `spawnOwnedProcess` accepts only that sealed projection, rejecting a source + admission, mutable input, foreign authority, aliased backend token, or + closing/closed resource before crossing the native boundary. + +The projection API also commits entry, depth, and byte limits; the exact +read-only and writable subtrees; the Ghidra/JDK/bridge/target roles; and the +canonical environment and VM-argument inputs. These are provider-neutral +resource roles and observations, not Win32 DTOs. #289 owns handle-relative +projection and readback, #290 owns sealing and verified DACL state, and #288 +consumes only the sealed result. The port snapshots every mutable projection +and launch container before its first asynchronous boundary, leases all +source/projection capabilities across native calls, and poisons/cleans +malformed aliased root or process results before returning a contract mismatch. +Native package work must implement this sealed-projection contract and may not +reintroduce the older admitted-file spawn signature. + +The verifier must include a writable mapping established before admission, +hard-link alias writes, ordinary and POSIX-semantics delete/rename, reparse +substitution, DLL search substitution, post-admission changes to scripts, JARs, +native libraries, and configuration, owner attempts to rewrite the sealed DACL, +and attempts to inherit or duplicate REA's retained authority handles. It must +also prove that Ghidra starts after the original JDK and Ghidra trees are renamed +or modified, demonstrating that no original runtime byte is used after +projection. Network and other filesystems whose semantics cannot satisfy the +selected proof fail closed. No implementation may fall back to the original +unbound pathname or batch launcher. + +## Process I/O and lifetime mechanism + +The proposed process implementation is addon-only. Before the application +publishes a `WindowsOwnedProcess`, it strictly parses a normalized ownership +report requiring a dedicated unnamed Job Object, atomic +`PROC_THREAD_ATTRIBUTE_JOB_LIST` assignment, execution assigned at creation, +kill-on-close enabled, breakaway disabled, no admitted nested job, a retained +non-inheritable owner handle, exactly one REA-owned Job handle, stdio-only +inherited handles, and verified membership. The application derives the policy +digest from a versioned ordered projection of every normalized field; it does +not accept an opaque backend digest. A missing, malformed, broader, or unknown report +poisons the native token and closes the Job rather than degrading to +create-then-assign or `taskkill`. This validates only the adapter's report; the +native implementation and real-Windows verifier must prove the actual attribute +list, queried limits, membership, and descendant behavior. + +The single-owner-handle claim means the addon creates no internal duplicate and +retains only its one non-inheritable Job handle. An unnamed Job prevents a new +open by name, but it does not stop another process that can obtain +`PROCESS_DUP_HANDLE` access to REA from duplicating that handle. Consequently +last-handle kill-on-close is claimed only against processes outside that handle- +theft boundary. Same-user handle theft remains unproven unless the native design +can protect the REA process handle and the verifier demonstrates failed +duplication attempts; it is not implied by the normalized report. + +One native resource owns the Job Object, process/thread handles, anonymous-pipe +endpoints, wait state, bounded output accumulators, and its reader/wait threads. +The launch sequence creates +non-inheritable parent pipe ends and supplies only the three intended child +stdio handles through `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`. Both the handle-list +and job-list attributes are installed in one correctly sized attribute list; +`bInheritHandles` is true as required by the handle-list contract, while every +unlisted native handle remains non-inheritable. + +The parent closes its duplicates of the child pipe ends immediately after +`CreateProcessW` returns. Reader threads exclusively own the remaining parent +read handles. + +Dedicated native reader threads continuously drain stdout and stderr regardless +of JavaScript consumption. Each accumulator records an overflow-checked total +byte count while retaining at most its committed byte limit. `snapshot()` copies +the retained prefix into new Node buffers; no external buffer aliases native +storage. A native wait thread observes process completion. Promise completion +crosses to the main thread through a size-one Node-API `ThreadSafeFunction` +using nonblocking calls; an already queued exit notification is coalesced rather +than blocking a native thread. Native threads acquire and release the TSFN and +never call V8, libuv, or JavaScript directly. `waitForExit` cancellation removes +only that waiter and cannot release the Job Object. + +Normal `close` is idempotent: transition once to closing and close the Job Object +to trigger kill-on-close. Process termination closes every inherited pipe write +end, allowing readers to drain retained tail bytes and receive EOF. If a reader +has not exited by the cleanup deadline, call `CancelSynchronousIo` with that +reader's thread handle; never close a read handle from underneath a synchronous +`ReadFile`. Join each reader before closing its read handle or freeing its +accumulator. A timeout keeps native state owned until all threads have exited; +it cannot detach or free live reader state. + +Environment teardown first aborts the TSFN. Every reader/wait thread stops +calling it on `napi_closing`, releases its acquisition, and exits. An +`napi_add_async_cleanup_hook` joins native threads without blocking the +JavaScript thread; native state is freed only after both async cleanup and the +TSFN finalizer have completed. Pending promises may be abandoned only after +JavaScript execution is unavailable. Object finalization requests the same +state transition but does not independently free state or silently detach a +live job. + +## Packaging and loading contract + +- Support is initially exact to Windows 10 / Windows Server 2016 or newer on + x64, and the Node ranges declared by the main package. Runtime loading probes + the required process-attribute APIs and fails closed; there is no suspended or + post-creation Job assignment fallback. +- Release CI builds from source and publishes the platform package before the + matching main package version. +- The main package pins the optional dependency to the exact same version. +- npm package integrity is the installation authenticity boundary. Release CI + writes the platform artifact SHA-256 into the version-matched main package's + generated metadata before publication. REA must compare that pinned value + with the addon bytes immediately before loading, require the exact package, + version, contract, Node-API level, and export set, and record the loaded + identity for diagnostics. Before first load, the process-global loader rejects + an existing `require.cache` entry for the exact canonical resolved addon path. + After acceptance it pins the returned module object, canonical path, and + identity for the process lifetime: a repeat request for that same tuple is + idempotent, while a different observable path or identity is rejected. Alias + loads and native images that are not observable through that exact JavaScript + loader entry belong to the excluded pre-existing same-user boundary; the + loader does not claim process-wide native-image enumeration. +- The addon bootstrap has a deliberately narrower threat boundary than the + runtime projection it creates. REA loads and validates the addon before it + starts Hopper, Ghidra, a target, or another provider-controlled process. npm + integrity plus the release-generated digest detects package drift, but a + hash-path-`require` handshake cannot prevent an already-running process under + the same Windows user from replacing bytes between verification and native + image mapping, restoring them afterward, injecting into REA, or preloading a + cache entry. REA does not claim resistance to that pre-existing same-user + bootstrap attacker. This is consistent with the separately documented + unproven same-user Job-handle theft boundary; it is not evidence for runtime + projection immutability. +- After the bootstrap-accepted addon is loaded, #289/#290 must project every + non-system byte that a provider or target can cause REA to execute or read as + authority, including Ghidra, the JDK, bridge/session inputs, target snapshot, + and runtime configuration. At the composition root, a bootstrap failure must + occur before a Hopper/Ghidra/provider client is constructed, a target is + opened or admitted, or provider/target code is run. After bootstrap succeeds, + no provider or target process may start before the complete projection is + sealed. Ordinary side-effect-free JavaScript module evaluation is not part of + that execution claim. The addon itself remains bound to the installed npm + release and is not recursively required to project itself before loading. +- A future claim that includes hostile pre-existing same-user processes needs a + different bootstrap, such as a signed separately protected launcher or a + native main executable. That stronger boundary is outside #287–#290 and must + not be inferred from package hashing or DACL readback. +- Runtime selection admits only the expected package, platform, architecture, + Node-API version, native contract version, and exported operation set. +- Missing optional dependencies, `--omit=optional`, unsupported filesystems, + nested-job conflicts, or native load failures produce typed unavailable + outcomes. They never trigger node-gyp, a download, PowerShell, `icacls`, or a + helper fallback. +- The current `taskkill` and pathname implementations remain explicitly limited + fallbacks and cannot set the native authority capability flags. + +## Verification + +Package verification must prove: + +- ordinary packed installation selects the exact Windows x64 package; +- installation with scripts disabled performs no build or download; +- omission or removal of optional dependencies fails closed with actionable + doctor output; +- registry/package-lock integrity authenticates the installed package set, + while the main-package metadata digest independently detects runtime artifact + drift and release-pair mismatch without acting as another signature; +- the exact main/platform version pair and platform-tarball/addon digest match + before native evaluation; +- digest, package, version, contract, Node-API, or manifest-declared export-set + mismatch prevents addon evaluation and leaves provider construction, target + admission, and provider/target launch counts at zero; an actual runtime export + or identity mismatch is detected immediately after evaluation and prevents + backend use, provider construction, target admission, and provider/target + launch; +- an exact resolved path already present in `require.cache` is rejected before + use, a second load of the pinned tuple is idempotent, and another observable + path or identity is rejected; +- unsupported or omitted packages never probe provider or target paths and + return the typed unavailable outcome; +- a successful bootstrap is followed by a sealed complete projection before + direct projected-Java launch; and +- the published main and platform packages resolve as one release set. + +Neither package verification nor the real proof may claim survival when a +same-user attacker can replace both the main metadata and platform package, +inject into REA, or preload an alias outside the exact observable cache entry. +Real proof output must retain this bootstrap limitation after #289/#290 pass. + +The self-hosted real-Windows verifier must separately prove the acceptance +criteria in #288, #289, and #290 while running as a non-administrator. Mocked +Node tests may verify TypeScript routing and failure translation but cannot +satisfy Job Object, reparse-point, file-identity, or DACL claims. + +## Consequences + +- Releases gain a coordinated Windows platform package and Windows build job. +- Installing without optional dependencies keeps the CLI usable but leaves the + stronger Windows authority unavailable. +- The native surface remains deliberately small and security-specific. +- Native reader threads add a small, explicit concurrency surface, but keep + output progress independent of JavaScript scheduling and preserve the public + Node-API ABI boundary. + +## Research basis + +- Node-API documentation states that ABI stability requires exclusive use of + Node-API/node-addon-api and excludes direct V8, libuv, and Node internals. +- `node-pty` demonstrates a public Node-API `ThreadSafeFunction` carrying a + Windows process-exit observation from a native wait thread. REA adopts the + thread-delivery pattern, not its process-tree enumeration or install-time + native build/copy workflow. +- `sharp` demonstrates platform-specific optional npm packages selected with + `os`/`cpu` metadata. REA adopts that distribution shape but not its + source-compilation fallback. +- `better-sqlite3` demonstrates broad prebuilt coverage, but its + prebuild-install-to-node-gyp fallback conflicts with REA's compiler-free + installation rule. + +These external repository summaries are discovery evidence. The implementation +must still be verified against current Node-API, npm, Win32, repository source, +package artifacts, and the real Windows runner. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5c2ea750c..ad28863c5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -9,3 +9,4 @@ states its implementation status separately. | [0001: Provider selection and analysis profiles](0001-provider-selection-and-analysis-profiles.md) | Accepted | Foundation shipped; capability work continues | | [0002: Controlled JavaScript replay authority and sandbox policy](0002-controlled-replay-authority-and-sandbox.md) | Accepted | Linux x86_64 extracted-module replay shipped | | [0003: Managed-code evidence and provider boundary](0003-managed-code-evidence-and-provider-boundary.md) | Accepted | PE/CLI triage shipped; deeper contracts planned | +| [0004: Windows native authority packaging](0004-windows-native-authority-packaging.md) | Proposed | Design only; P0 limitations remain | diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 2afa2d438..be914edc9 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -8,7 +8,7 @@ "client": "2.0.0-beta.4", "core": "2.0.0-beta.4" }, - "skill_version": "21" + "skill_version": "22" }, "tools": { "total": 95, @@ -566,10 +566,10 @@ "mcp_resource_templates": 8 }, "digests": { - "tools_sha256": "4f19c5773a44980e176978f05626f2d82c95d49e4b86d024ce515c3ff1670bae", + "tools_sha256": "28aad2ec69ca46ce9107fa49bff07768d91f194b602f1f32059352e61eb6c19f", "prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707", "resources_sha256": "5dca0fcdad3e78d71a1b6ef7b4f0c12bdcd8e3ec11fcbdd9ccd3a68edadf5c80", - "combined_sha256": "b6840d7c79ffdfe8a2ef65fb66c1694f3631bacbc22a0456abbe12aca2d53969" + "combined_sha256": "266ea209ddc79eaf85d7ad160e8d3c8ce55cfda6d4b2ca1abb2e4554a914cfd1" } } } diff --git a/package.json b/package.json index be8ed139c..63c06de04 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,14 @@ "node": "^22.19.0 || >=24.11.0" }, "packageManager": "npm@11.16.0", + "rea": { + "windowsNativeAuthority": { + "packageName": "@rea/windows-x64", + "contractVersion": 1, + "nodeApiVersion": 9, + "artifactSha256": null + } + }, "files": [ "dist", "install.sh", diff --git a/scripts/generate-package-metadata.mjs b/scripts/generate-package-metadata.mjs index 945d392dd..487ac6ca0 100644 --- a/scripts/generate-package-metadata.mjs +++ b/scripts/generate-package-metadata.mjs @@ -30,6 +30,16 @@ const versionOf = (name) => { throw new Error(`Missing locked ${name} version`); return value; }; +const windowsNativeAuthority = packageJson.rea?.windowsNativeAuthority; +if ( + typeof windowsNativeAuthority?.packageName !== "string" || + !Number.isInteger(windowsNativeAuthority.contractVersion) || + !Number.isInteger(windowsNativeAuthority.nodeApiVersion) || + (windowsNativeAuthority.artifactSha256 !== null && + (typeof windowsNativeAuthority.artifactSha256 !== "string" || + !/^[0-9a-f]{64}$/u.test(windowsNativeAuthority.artifactSha256))) +) + throw new Error("Invalid Windows native authority package metadata"); const source = `/** Generated from package.json and package-lock.json; do not edit. */ export const PACKAGE_METADATA = { name: ${JSON.stringify(packageJson.name)}, @@ -38,6 +48,13 @@ export const PACKAGE_METADATA = { clientSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/client"))}, coreSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/core"))}, skillVersion: ${JSON.stringify(skillVersion)}, + windowsNativeAuthority: { + packageName: ${JSON.stringify(windowsNativeAuthority.packageName)}, + packageVersion: ${JSON.stringify(packageJson.version)}, + contractVersion: ${JSON.stringify(windowsNativeAuthority.contractVersion)}, + nodeApiVersion: ${JSON.stringify(windowsNativeAuthority.nodeApiVersion)}, + artifactSha256: ${JSON.stringify(windowsNativeAuthority.artifactSha256)}, + }, } as const; `; const outputPath = join(root, "src/generatedPackageMetadata.ts"); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 1ec2b05e3..69f79a782 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -25,6 +25,37 @@ export type ToolAvailabilityReason = | "unsupported_host" | "policy_disabled"; +type ToolKind = (typeof TOOL_CONTRACTS)[number]["kind"]; +type ProviderDescriptor = { + readonly available: boolean; + readonly reason: string | null; +}; +type AvailabilityPolicy = { + readonly processCaptureEnabled: boolean; + readonly evidenceFileRoots: number; + readonly browserObservationEnabled?: boolean; + readonly electronObservationEnabled?: boolean; + readonly javascriptReplayEnabled?: boolean; + readonly managedRuntimeEnabled?: boolean; +}; +type Availability = { + readonly reason: ToolAvailabilityReason; + readonly remediation: string | null; +}; +type AvailabilityContext = { + readonly name: string; + readonly kind: ToolKind; + readonly targetOpen: boolean; + readonly targetKind: + | "executable" + | "database" + | "archive" + | "artifact" + | undefined; + readonly descriptors: ReadonlyMap; + readonly policy: AvailabilityPolicy; +}; + const ENHANCED_REQUIREMENTS: Readonly> = { swift_classes: ["list_procedures"], get_objc_classes: ["list_names"], @@ -51,28 +82,21 @@ const ENHANCED_REQUIREMENTS: Readonly> = { /** Build stable per-operation availability without hiding familiar tools. */ export const buildCapabilityInventory = ( sessionStatus: JsonValue, - policy: { - readonly processCaptureEnabled: boolean; - readonly evidenceFileRoots: number; - readonly browserObservationEnabled?: boolean; - readonly electronObservationEnabled?: boolean; - readonly javascriptReplayEnabled?: boolean; - readonly managedRuntimeEnabled?: boolean; - }, + policy: AvailabilityPolicy, ) => { const status = statusSchema.parse(sessionStatus); const descriptors = new Map( status.capabilities.map((descriptor) => [descriptor.operation, descriptor]), ); return TOOL_CONTRACTS.map((contract) => { - const availability = availabilityFor( - contract.name, - contract.kind, - status.open, - status.kind, + const availability = availabilityFor({ + name: contract.name, + kind: contract.kind, + targetOpen: status.open, + targetKind: status.kind, descriptors, policy, - ); + }); return { name: contract.name, surface: contract.kind, @@ -90,27 +114,19 @@ export const buildCapabilityInventory = ( }).sort((left, right) => left.name.localeCompare(right.name)); }; -const availabilityFor = ( - name: string, - kind: (typeof TOOL_CONTRACTS)[number]["kind"], - targetOpen: boolean, - targetKind: "executable" | "database" | "archive" | "artifact" | undefined, - descriptors: ReadonlyMap< - string, - { readonly available: boolean; readonly reason: string | null } - >, - policy: { - readonly processCaptureEnabled: boolean; - readonly evidenceFileRoots: number; - readonly browserObservationEnabled?: boolean; - readonly electronObservationEnabled?: boolean; - readonly javascriptReplayEnabled?: boolean; - readonly managedRuntimeEnabled?: boolean; - }, -): { - readonly reason: ToolAvailabilityReason; - readonly remediation: string | null; -} => { +const availabilityFor = (context: AvailabilityContext): Availability => { + const policyDecision = policyAvailability(context); + if (policyDecision !== null) return policyDecision; + const targetDecision = targetAvailability(context); + if (targetDecision !== null) return targetDecision; + return providerAvailability(context); +}; + +const policyAvailability = ({ + name, + kind, + policy, +}: AvailabilityContext): Availability | null => { if (name === "capture_process_scenario" && !policy.processCaptureEnabled) return { reason: "policy_disabled", @@ -159,6 +175,14 @@ const availabilityFor = ( "Enable Electron observation and configure a loopback CDP endpoint and canonical file roots.", }; if (kind === "session") return { reason: "available", remediation: null }; + return null; +}; + +const targetAvailability = ({ + kind, + targetKind, + targetOpen, +}: AvailabilityContext): Availability | null => { if (!targetOpen) return { reason: "target_required", @@ -175,6 +199,14 @@ const availabilityFor = ( remediation: "Inventory or extract a native executable, then call open_binary on that executable.", }; + return null; +}; + +const providerAvailability = ({ + descriptors, + kind, + name, +}: AvailabilityContext): Availability => { if (kind === "enhanced") return composedAvailability(name, descriptors); const descriptor = descriptors.get(name); if (descriptor === undefined) diff --git a/src/application/WindowsCapabilities.ts b/src/application/WindowsCapabilities.ts index 401e58b1c..f13cbc993 100644 --- a/src/application/WindowsCapabilities.ts +++ b/src/application/WindowsCapabilities.ts @@ -21,6 +21,7 @@ export interface WindowsCapabilityDependencies { probeSymlinkCreation(): Promise; probeNoFollowOpen(): WindowsCapabilityOutcome; probePrivateAcl(): WindowsCapabilityOutcome; + probeNativeAuthority(): WindowsCapabilityOutcome; probeUnixDomainSocket(): WindowsCapabilityOutcome; probePty(): Promise; } @@ -33,6 +34,7 @@ export interface WindowsCapabilityReport { symlink_creation: WindowsCapabilityOutcome; no_follow_open: WindowsCapabilityOutcome; private_acl: WindowsCapabilityOutcome; + native_authority: WindowsCapabilityOutcome; unix_domain_socket: WindowsCapabilityOutcome; pty: WindowsCapabilityOutcome; }>; @@ -53,6 +55,7 @@ export const probeWindowsCapabilities = async ( symlink_creation: symlinkCreation, no_follow_open: dependencies.probeNoFollowOpen(), private_acl: dependencies.probePrivateAcl(), + native_authority: dependencies.probeNativeAuthority(), unix_domain_socket: dependencies.probeUnixDomainSocket(), pty: { available: pty.available, @@ -67,16 +70,16 @@ const systemDependencies = (): WindowsCapabilityDependencies => ({ architecture: process.arch, probeSymlinkCreation, probeNoFollowOpen: () => - typeof constants.O_NOFOLLOW === "number" - ? { available: true, reason: null } - : { - available: false, - reason: "O_NOFOLLOW is unavailable in this Node runtime", - }, + systemNoFollowOpenCapability(process.platform, constants.O_NOFOLLOW), probePrivateAcl: () => ({ available: false, reason: "Windows ACL enforcement is not implemented by this REA build", }), + probeNativeAuthority: () => ({ + available: false, + reason: + "Windows native authority package is not implemented by this REA build", + }), probeUnixDomainSocket: () => process.platform === "win32" ? { @@ -87,6 +90,24 @@ const systemDependencies = (): WindowsCapabilityDependencies => ({ probePty: probeProcessCaptureCapability, }); +/** Report pathname no-follow support without treating it as Windows authority. */ +export const systemNoFollowOpenCapability = ( + platform: NodeJS.Platform, + noFollow: number | undefined, +): WindowsCapabilityOutcome => { + if (platform === "win32") + return { + available: false, + reason: "Windows reparse-safe handle admission is not implemented", + }; + return typeof noFollow === "number" + ? { available: true, reason: null } + : { + available: false, + reason: "O_NOFOLLOW is unavailable in this Node runtime", + }; +}; + const probeSymlinkCreation = async (): Promise => { const root = await mkdtemp(join(tmpdir(), "rea-symlink-probe-")); const target = join(root, "target"); @@ -106,6 +127,11 @@ const probeSymlinkCreation = async (): Promise => { : "symlink creation failed", }; } finally { - await rm(root, { recursive: true, force: true }); + try { + await rm(root, { recursive: true, force: true }); + } catch { + // Cleanup failure must not replace the machine-readable probe outcome. + // The probe root lives beneath the OS temporary directory and is unique. + } } }; diff --git a/src/application/WindowsNativeAuthority.ts b/src/application/WindowsNativeAuthority.ts new file mode 100644 index 000000000..d93940663 --- /dev/null +++ b/src/application/WindowsNativeAuthority.ts @@ -0,0 +1,1379 @@ +import { createHash } from "node:crypto"; + +import { err, ok, type Result } from "../domain/result.js"; +import { z } from "zod"; + +/** Stable operation names exposed by the Windows native authority boundary. */ +export type WindowsNativeAuthorityOperation = + | "load_native_authority" + | "admit_path" + | "create_private_root" + | "spawn_owned_process" + | "observe_process" + | "wait_for_exit" + | "close_resource"; + +/** Expected failures translated from the optional native adapter boundary. */ +export type WindowsNativeAuthorityFailureReason = + | "unavailable" + | "invalid_input" + | "policy_rejected" + | "identity_drift" + | "access_denied" + | "resource_exhausted" + | "timeout" + | "cancelled" + | "native_contract_mismatch" + | "native_failure"; + +/** Provider-neutral failure from one Windows native authority operation. */ +export class WindowsNativeAuthorityError extends Error { + readonly _tag = "WindowsNativeAuthorityError" as const; + + constructor( + readonly operation: WindowsNativeAuthorityOperation, + readonly reason: WindowsNativeAuthorityFailureReason, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** Loaded native contract identity used for package and capability diagnostics. */ +export interface WindowsNativeAuthorityIdentity { + readonly packageName: string; + readonly packageVersion: string; + readonly contractVersion: number; + readonly nodeApiVersion: number; + readonly artifactSha256: string; +} + +/** Cancellation and one absolute deadline shared by every startup phase. */ +export interface WindowsNativeOperationControl { + readonly deadlineMs: number; + readonly signal?: AbortSignal; +} + +/** Input for atomic path admission under the initial fail-closed policy. */ +export interface WindowsPathAdmissionInput< + TKind extends "file" | "directory" = "file" | "directory", +> extends WindowsNativeOperationControl { + readonly path: string; + readonly kind: TKind; + readonly reparsePolicy: "reject_all"; + readonly expectedSha256?: string; +} + +interface WindowsAdmittedPathObservation { + readonly kind: TKind; + readonly requestedPath: string; + readonly finalPath: string; + readonly filesystem: "ntfs"; + readonly volumeIdentity: string; + readonly fileIdentity: string; + readonly stableIdentity: string; + readonly reparseDisposition: "absent"; +} + +/** Backend observation for a handle-admitted regular file. */ +export interface WindowsAdmittedFileObservation + extends WindowsAdmittedPathObservation<"file"> { + readonly sha256: string; +} + +/** Backend observation for a handle-admitted directory. */ +export interface WindowsAdmittedDirectoryObservation + extends WindowsAdmittedPathObservation<"directory"> { + readonly sha256: null; +} + +const windowsPathIdentitySchema = z.strictObject({ + requestedPath: z.string().min(1), + finalPath: z.string().min(1), + filesystem: z.literal("ntfs"), + volumeIdentity: z.string().regex(/^[0-9a-f]{16}$/u), + fileIdentity: z.string().regex(/^[0-9a-f]{32}$/u), + reparseDisposition: z.literal("absent"), +}); + +const admittedFileObservationSchema = windowsPathIdentitySchema.extend({ + kind: z.literal("file"), + sha256: z.string().regex(/^[0-9a-f]{64}$/u), +}); + +const admittedDirectoryObservationSchema = windowsPathIdentitySchema.extend({ + kind: z.literal("directory"), + sha256: z.null(), +}); + +const privateDaclObservationSchema = z.strictObject({ + objectKind: z.enum(["directory", "file"]), + owner: z.literal("current_user"), + dacl: z.literal("present"), + protection: z.enum(["protected", "inherited_from_protected_parent"]), + aceDisposition: z.enum(["explicit", "inherited"]), + propagation: z.enum(["none", "container_and_object"]), + additionalTrustees: z.literal("absent"), + broadAllowEntries: z.literal("absent"), + currentUserRights: z.enum(["read_execute", "modify"]), + systemRights: z.literal("full_control"), + ownerRights: z.literal("write_dac_write_owner_denied"), + descriptorSha256: z.string().regex(/^[0-9a-f]{64}$/u), + verifiedObjectCount: z.number().int().nonnegative(), + policyManifestSha256: z.string().regex(/^[0-9a-f]{64}$/u), +}); + +const explicitPolicySchema = privateDaclObservationSchema.extend({ + protection: z.literal("protected"), + aceDisposition: z.literal("explicit"), +}); + +const rootDirectoryPolicySchema = explicitPolicySchema.extend({ + objectKind: z.literal("directory"), + propagation: z.literal("none"), + currentUserRights: z.literal("read_execute"), + verifiedObjectCount: z.literal(1), +}); + +const readonlyDirectoryPolicySchema = explicitPolicySchema.extend({ + objectKind: z.literal("directory"), + propagation: z.literal("container_and_object"), + currentUserRights: z.literal("read_execute"), +}); + +const readonlyFilePolicySchema = explicitPolicySchema.extend({ + objectKind: z.literal("file"), + propagation: z.literal("none"), + currentUserRights: z.literal("read_execute"), +}); + +const writableDirectoryPolicySchema = explicitPolicySchema.extend({ + objectKind: z.literal("directory"), + propagation: z.literal("container_and_object"), + currentUserRights: z.literal("modify"), +}); + +const writableFilePolicySchema = privateDaclObservationSchema.extend({ + objectKind: z.literal("file"), + protection: z.literal("inherited_from_protected_parent"), + aceDisposition: z.literal("inherited"), + propagation: z.literal("none"), + currentUserRights: z.literal("modify"), +}); + +const privateRootObservationSchema = z.strictObject({ + canonicalPath: z.string().min(1), + stableIdentity: z.string().min(1), + state: z.literal("sealed"), + manifestSha256: z.string().regex(/^[0-9a-f]{64}$/u), + launchSha256: z.string().regex(/^[0-9a-f]{64}$/u), + fileCount: z.number().int().nonnegative(), + directoryCount: z.number().int().nonnegative(), + totalBytes: z.number().int().nonnegative(), + entrypoint: z.strictObject({ + relativePath: z.string().min(1), + sha256: z.string().regex(/^[0-9a-f]{64}$/u), + stableIdentity: z.string().min(1), + }), + security: z.strictObject({ + manifestSha256: z.string().regex(/^[0-9a-f]{64}$/u), + rootDirectory: rootDirectoryPolicySchema, + readonlyDirectories: readonlyDirectoryPolicySchema, + readonlyFiles: readonlyFilePolicySchema, + writableDirectories: writableDirectoryPolicySchema, + writableFiles: writableFilePolicySchema, + }), +}); + +const jobOwnershipObservationSchema = z.strictObject({ + jobObject: z.literal("dedicated_unnamed"), + assignment: z.literal("proc_thread_attribute_job_list"), + processExecution: z.literal("job_assigned_at_creation"), + killOnJobClose: z.literal("enabled"), + breakaway: z.literal("disabled"), + nestedJob: z.literal("absent"), + ownerHandle: z.literal("retained_non_inheritable"), + jobHandleOwnership: z.literal("single_rea_handle"), + inheritedHandles: z.literal("stdio_only"), + membership: z.literal("verified"), +}); + +const ownedProcessExitSchema = z.strictObject({ + status: z.enum(["exited", "terminated"]), + exitCode: z.number().int().nullable(), +}); + +const ownedProcessCloseSchema = z.strictObject({ + status: z.enum(["already_empty", "terminated"]), +}); + +const ownedProcessStreamSnapshotSchema = z.strictObject({ + retained: z.instanceof(Uint8Array), + totalBytes: z.number().int().nonnegative(), + truncated: z.boolean(), +}); + +const ownedProcessSnapshotSchema = z.strictObject({ + stdout: ownedProcessStreamSnapshotSchema, + stderr: ownedProcessStreamSnapshotSchema, + exit: ownedProcessExitSchema.nullable(), +}); + +/** Verified provider-neutral policy observations for a private runtime root. */ +export interface WindowsPrivateDaclObservation { + readonly objectKind: "directory" | "file"; + readonly owner: "current_user"; + readonly dacl: "present"; + readonly protection: "protected" | "inherited_from_protected_parent"; + readonly aceDisposition: "explicit" | "inherited"; + readonly propagation: "none" | "container_and_object"; + readonly additionalTrustees: "absent"; + readonly broadAllowEntries: "absent"; + readonly currentUserRights: "read_execute" | "modify"; + readonly systemRights: "full_control"; + readonly ownerRights: "write_dac_write_owner_denied"; + readonly descriptorSha256: string; + readonly verifiedObjectCount: number; + readonly policyManifestSha256: string; +} + +/** Exact normalized DACL policies read back from a sealed runtime tree. */ +export interface WindowsPrivateRootSecurityObservation { + readonly manifestSha256: string; + readonly rootDirectory: WindowsPrivateDaclObservation & { + readonly objectKind: "directory"; + readonly protection: "protected"; + readonly aceDisposition: "explicit"; + readonly propagation: "none"; + readonly currentUserRights: "read_execute"; + readonly verifiedObjectCount: 1; + }; + readonly readonlyDirectories: WindowsPrivateDaclObservation & { + readonly objectKind: "directory"; + readonly protection: "protected"; + readonly aceDisposition: "explicit"; + readonly propagation: "container_and_object"; + readonly currentUserRights: "read_execute"; + }; + readonly readonlyFiles: WindowsPrivateDaclObservation & { + readonly objectKind: "file"; + readonly protection: "protected"; + readonly aceDisposition: "explicit"; + readonly propagation: "none"; + readonly currentUserRights: "read_execute"; + }; + readonly writableDirectories: WindowsPrivateDaclObservation & { + readonly objectKind: "directory"; + readonly protection: "protected"; + readonly aceDisposition: "explicit"; + readonly propagation: "container_and_object"; + readonly currentUserRights: "modify"; + }; + readonly writableFiles: WindowsPrivateDaclObservation & { + readonly objectKind: "file"; + readonly protection: "inherited_from_protected_parent"; + readonly aceDisposition: "inherited"; + readonly propagation: "none"; + readonly currentUserRights: "modify"; + }; +} + +/** Verified provider-neutral policy observations for a private runtime root. */ +export interface WindowsPrivateRootObservation { + readonly canonicalPath: string; + readonly stableIdentity: string; + readonly state: "sealed"; + readonly manifestSha256: string; + readonly launchSha256: string; + readonly fileCount: number; + readonly directoryCount: number; + readonly totalBytes: number; + readonly entrypoint: Readonly<{ + relativePath: string; + sha256: string; + stableIdentity: string; + }>; + readonly security: WindowsPrivateRootSecurityObservation; +} + +/** Hard bounds applied while projecting admitted runtime inputs. */ +export interface WindowsRuntimeProjectionLimits { + readonly entries: number; + readonly depth: number; + readonly bytes: number; +} + +/** One admitted directory tree assigned a provider-neutral runtime role. */ +export interface WindowsRuntimeTreeProjection { + readonly role: "ghidra" | "jdk"; + readonly source: WindowsAdmittedDirectory; + readonly destination: string; +} + +/** One admitted file assigned a provider-neutral runtime role. */ +export interface WindowsRuntimeFileProjection { + readonly role: "bridge" | "session" | "target"; + readonly source: WindowsAdmittedFile; + readonly destination: string; +} + +/** Immutable bounds applied before an owned process is started. */ +export interface WindowsOwnedProcessLimits { + readonly stdoutBytes: number; + readonly stderrBytes: number; +} + +/** Final process observation independent of Win32 status structures. */ +export interface WindowsOwnedProcessExit { + readonly status: "exited" | "terminated"; + readonly exitCode: number | null; +} + +/** Normalized Job Object policy proven before an owned process is returned. */ +export interface WindowsJobOwnershipObservation { + readonly jobObject: "dedicated_unnamed"; + readonly assignment: "proc_thread_attribute_job_list"; + readonly processExecution: "job_assigned_at_creation"; + readonly killOnJobClose: "enabled"; + readonly breakaway: "disabled"; + readonly nestedJob: "absent"; + readonly ownerHandle: "retained_non_inheritable"; + readonly jobHandleOwnership: "single_rea_handle"; + readonly inheritedHandles: "stdio_only"; + readonly membership: "verified"; + readonly policySha256: string; +} + +/** Idempotent close observation for a dedicated process Job Object. */ +export interface WindowsOwnedProcessClose { + readonly status: "already_empty" | "terminated"; +} + +/** One continuously drained stream with bounded retained bytes. */ +export interface WindowsOwnedProcessStreamSnapshot { + readonly retained: Uint8Array; + readonly totalBytes: number; + readonly truncated: boolean; +} + +/** Detached bounded output and exit observations for one owned process. */ +export interface WindowsOwnedProcessSnapshot { + readonly stdout: WindowsOwnedProcessStreamSnapshot; + readonly stderr: WindowsOwnedProcessStreamSnapshot; + readonly exit: WindowsOwnedProcessExit | null; +} + +/** Adapter-private resource token; application callers never receive it. */ +export interface WindowsNativeBackendResource { + close(): Promise>; +} + +/** Adapter-private process token with continuously drained bounded output. */ +export interface WindowsNativeBackendProcess + extends WindowsNativeBackendResource { + readonly processId: number; + readonly ownership: unknown; + snapshot(): WindowsOwnedProcessSnapshot; + waitForExit( + signal?: AbortSignal, + ): Promise>; + closeProcess(): Promise< + Result + >; +} + +/** Backend result for one newly admitted file. */ +export interface WindowsNativeAdmittedFile { + readonly resource: WindowsNativeBackendResource; + readonly observation: unknown; +} + +/** Backend result for one newly admitted directory. */ +export interface WindowsNativeAdmittedDirectory { + readonly resource: WindowsNativeBackendResource; + readonly observation: unknown; +} + +/** Backend result for one newly created private root. */ +export interface WindowsNativePrivateRoot { + readonly resource: WindowsNativeBackendResource; + readonly observation: unknown; +} + +/** Adapter-private admitted tree without application wrapper values. */ +export interface WindowsNativeRuntimeTreeProjection { + readonly role: WindowsRuntimeTreeProjection["role"]; + readonly source: WindowsNativeBackendResource; + readonly destination: string; +} + +/** Adapter-private admitted file without application wrapper values. */ +export interface WindowsNativeRuntimeFileProjection { + readonly role: WindowsRuntimeFileProjection["role"]; + readonly source: WindowsNativeBackendResource; + readonly destination: string; +} + +/** Narrow contract implemented by the optional platform package. */ +export interface WindowsNativeAuthorityBackend { + readonly identity: WindowsNativeAuthorityIdentity | null; + admitFile( + input: WindowsPathAdmissionInput<"file">, + ): Promise>; + admitDirectory( + input: WindowsPathAdmissionInput<"directory">, + ): Promise< + Result + >; + createPrivateRoot( + input: Readonly<{ + parent: WindowsNativeBackendResource; + prefix: string; + trees: readonly WindowsNativeRuntimeTreeProjection[]; + files: readonly WindowsNativeRuntimeFileProjection[]; + writableDirectories: readonly string[]; + entrypoint: Readonly<{ + treeRole: "jdk"; + relativePath: string; + }>; + launch: Readonly<{ + arguments: readonly string[]; + environment: Readonly>; + }>; + limits: WindowsRuntimeProjectionLimits; + control: WindowsNativeOperationControl; + }>, + ): Promise>; + spawnOwnedProcess(input: { + readonly runtimeProjection: WindowsNativeBackendResource; + readonly limits: WindowsOwnedProcessLimits; + readonly control: WindowsNativeOperationControl; + }): Promise>; +} + +type ResourceStatus = "open" | "closing" | "closed"; +type ResourceKind = "file" | "directory" | "privateRoot" | "process"; + +interface CloseDeferred { + readonly promise: Promise>; + readonly resolve: (value: Result) => void; +} + +interface ResourceState { + readonly owner: WindowsNativeAuthority; + readonly backend: WindowsNativeBackendResource; + readonly closeBackend: () => Promise< + Result + >; + readonly kind: ResourceKind; + status: ResourceStatus; + leaseCount: number; + closePromise?: Promise>; + closeDeferred?: CloseDeferred; +} + +interface ResolvedResource { + readonly backend: WindowsNativeBackendResource; + readonly state: ResourceState; +} + +interface ResolvedTreeProjection { + readonly role: WindowsRuntimeTreeProjection["role"]; + readonly backend: WindowsNativeBackendResource; + readonly destination: string; + readonly state: ResourceState; +} + +interface ResolvedFileProjection { + readonly role: WindowsRuntimeFileProjection["role"]; + readonly backend: WindowsNativeBackendResource; + readonly destination: string; + readonly state: ResourceState; +} + +const resourceStates = new WeakMap(); +const backendTokenStates = new WeakMap< + WindowsNativeBackendResource, + ResourceState +>(); + +/** Handle-admitted regular file scoped to its issuing authority instance. */ +export interface WindowsAdmittedFile extends WindowsAdmittedFileObservation { + /** Close the retained handle once and make the capability unusable. */ + close(): Promise>; +} + +/** Handle-admitted directory scoped to its issuing authority instance. */ +export interface WindowsAdmittedDirectory + extends WindowsAdmittedDirectoryObservation { + /** Close the retained handle once and make the capability unusable. */ + close(): Promise>; +} + +/** Private runtime root scoped to its issuing authority instance. */ +export interface WindowsPrivateRoot extends WindowsPrivateRootObservation { + /** Close the retained root authority once and reject later use. */ + close(): Promise>; +} + +/** Opaque owned process with bounded output and kill-on-close lifetime. */ +export interface WindowsOwnedProcess { + /** Provider-neutral process identifier retained for diagnostics only. */ + readonly processId: number; + + /** Parsed Job Object policy associated atomically with process creation. */ + readonly ownership: WindowsJobOwnershipObservation; + + /** Read the latest detached, bounded output and exit observation. */ + snapshot(): WindowsOwnedProcessSnapshot; + + /** Wait for exit; cancellation stops only this wait and never closes the job. */ + waitForExit( + signal?: AbortSignal, + ): Promise>; + + /** Close the Job Object once and observe whether it terminated processes. */ + close(): Promise< + Result + >; +} + +/** Input for creating one private child beneath an admitted directory. */ +export interface WindowsPrivateRootInput extends WindowsNativeOperationControl { + readonly parent: WindowsAdmittedDirectory; + readonly prefix: string; + readonly trees: readonly WindowsRuntimeTreeProjection[]; + readonly files: readonly WindowsRuntimeFileProjection[]; + readonly writableDirectories: readonly string[]; + readonly entrypoint: Readonly<{ + readonly treeRole: "jdk"; + readonly relativePath: string; + }>; + readonly launch: Readonly<{ + readonly arguments: readonly string[]; + readonly environment: Readonly>; + }>; + readonly limits: WindowsRuntimeProjectionLimits; +} + +/** Input for native process creation already assigned to its Job Object. */ +export interface WindowsOwnedProcessInput + extends WindowsNativeOperationControl { + readonly runtimeProjection: WindowsPrivateRoot; + readonly limits: WindowsOwnedProcessLimits; +} + +const parseAdmittedFileObservation = ( + value: unknown, + requestedPath: string, +): Result => { + const parsed = admittedFileObservationSchema.safeParse(value); + if (!parsed.success || parsed.data.requestedPath !== requestedPath) + return malformedAdmissionObservation(); + return ok({ + ...parsed.data, + stableIdentity: stablePathIdentity(parsed.data), + }); +}; + +const parseAdmittedDirectoryObservation = ( + value: unknown, + requestedPath: string, +): Result => { + const parsed = admittedDirectoryObservationSchema.safeParse(value); + if (!parsed.success || parsed.data.requestedPath !== requestedPath) + return malformedAdmissionObservation(); + return ok({ + ...parsed.data, + stableIdentity: stablePathIdentity(parsed.data), + }); +}; + +const parsePrivateRootObservation = ( + value: unknown, +): Result => { + const parsed = privateRootObservationSchema.safeParse(value); + if ( + !parsed.success || + parsed.data.fileCount !== + parsed.data.security.readonlyFiles.verifiedObjectCount + + parsed.data.security.writableFiles.verifiedObjectCount || + parsed.data.directoryCount !== + 1 + + parsed.data.security.readonlyDirectories.verifiedObjectCount + + parsed.data.security.writableDirectories.verifiedObjectCount || + parsed.data.security.manifestSha256 !== + privateSecurityManifestSha256( + parsed.data.manifestSha256, + parsed.data.launchSha256, + parsed.data.security, + ) + ) + return err( + new WindowsNativeAuthorityError( + "create_private_root", + "native_contract_mismatch", + "Windows native adapter returned an invalid private DACL observation", + ), + ); + return ok(parsed.data); +}; + +const privateSecurityManifestSha256 = ( + projectionManifestSha256: string, + launchSha256: string, + security: Omit, +): string => { + const commitment = { + version: 1, + projectionManifestSha256, + launchSha256, + rootDirectory: policyCommitment(security.rootDirectory), + readonlyDirectories: policyCommitment(security.readonlyDirectories), + readonlyFiles: policyCommitment(security.readonlyFiles), + writableDirectories: policyCommitment(security.writableDirectories), + writableFiles: policyCommitment(security.writableFiles), + }; + return createHash("sha256").update(JSON.stringify(commitment)).digest("hex"); +}; + +const policyCommitment = (policy: WindowsPrivateDaclObservation) => ({ + verifiedObjectCount: policy.verifiedObjectCount, + policyManifestSha256: policy.policyManifestSha256, +}); + +const jobOwnershipPolicySha256 = ( + ownership: Omit, +): string => + createHash("sha256") + .update( + JSON.stringify({ + version: 1, + jobObject: ownership.jobObject, + assignment: ownership.assignment, + processExecution: ownership.processExecution, + killOnJobClose: ownership.killOnJobClose, + breakaway: ownership.breakaway, + nestedJob: ownership.nestedJob, + ownerHandle: ownership.ownerHandle, + jobHandleOwnership: ownership.jobHandleOwnership, + inheritedHandles: ownership.inheritedHandles, + membership: ownership.membership, + }), + ) + .digest("hex"); + +const stablePathIdentity = (identity: { + readonly filesystem: "ntfs"; + readonly volumeIdentity: string; + readonly fileIdentity: string; +}): string => + `${identity.filesystem}:${identity.volumeIdentity}:${identity.fileIdentity}`; + +const malformedAdmissionObservation = (): Result< + T, + WindowsNativeAuthorityError +> => + err( + new WindowsNativeAuthorityError( + "admit_path", + "native_contract_mismatch", + "Windows native adapter returned an invalid path identity observation", + ), + ); + +/** Application-owned authority wrapper that scopes every native resource. */ +export class WindowsNativeAuthority { + constructor(readonly backend: WindowsNativeAuthorityBackend) {} + + /** Loaded native contract identity after package validation. */ + get identity(): WindowsNativeAuthorityIdentity | null { + return this.backend.identity; + } + + /** Admit one file or directory without following a reparse point. */ + admitPath( + input: WindowsPathAdmissionInput<"file">, + ): Promise>; + admitPath( + input: WindowsPathAdmissionInput<"directory">, + ): Promise>; + async admitPath( + input: WindowsPathAdmissionInput, + ): Promise< + Result< + WindowsAdmittedFile | WindowsAdmittedDirectory, + WindowsNativeAuthorityError + > + > { + const admissionInput: WindowsPathAdmissionInput = { + path: input.path, + kind: input.kind, + reparsePolicy: input.reparsePolicy, + deadlineMs: input.deadlineMs, + ...(input.expectedSha256 === undefined + ? {} + : { expectedSha256: input.expectedSha256 }), + ...(input.signal === undefined ? {} : { signal: input.signal }), + }; + + if (input.kind === "file") { + const admitted = await this.backend.admitFile( + admissionInput as WindowsPathAdmissionInput<"file">, + ); + if (!admitted.ok) return admitted; + const observation = parseAdmittedFileObservation( + admitted.value.observation, + admissionInput.path, + ); + if (!observation.ok) { + await poisonAliasedToken(admitted.value.resource, () => + admitted.value.resource.close(), + ); + return observation; + } + const resource = createResource({ + authority: this, + operation: "admit_path", + kind: "file", + observation: observation.value, + backend: admitted.value.resource, + }); + if (!resource.ok) + await poisonAliasedToken(admitted.value.resource, () => + admitted.value.resource.close(), + ); + return resource; + } + + const admitted = await this.backend.admitDirectory( + admissionInput as WindowsPathAdmissionInput<"directory">, + ); + if (!admitted.ok) return admitted; + const observation = parseAdmittedDirectoryObservation( + admitted.value.observation, + admissionInput.path, + ); + if (!observation.ok) { + await poisonAliasedToken(admitted.value.resource, () => + admitted.value.resource.close(), + ); + return observation; + } + const resource = createResource({ + authority: this, + operation: "admit_path", + kind: "directory", + observation: observation.value, + backend: admitted.value.resource, + }); + if (!resource.ok) + await poisonAliasedToken(admitted.value.resource, () => + admitted.value.resource.close(), + ); + return resource; + } + + /** Create and verify a protected runtime root beneath owned authority. */ + async createPrivateRoot( + input: WindowsPrivateRootInput, + ): Promise> { + const prefix = input.prefix; + const writableDirectories = [...input.writableDirectories]; + const entrypoint = { + treeRole: input.entrypoint.treeRole, + relativePath: input.entrypoint.relativePath, + }; + const launch = { + arguments: [...input.launch.arguments], + environment: { ...input.launch.environment }, + }; + const limits = { + entries: input.limits.entries, + depth: input.limits.depth, + bytes: input.limits.bytes, + }; + const controlSnapshot = control(input); + + const parent = ownedResource( + this, + input.parent, + "create_private_root", + "directory", + ); + if (!parent.ok) return parent; + + const trees = ownedRuntimeTrees(this, input.trees); + if (!trees.ok) return trees; + + const files = ownedRuntimeFiles(this, input.files); + if (!files.ok) return files; + + const leases: ResourceState[] = [ + parent.value.state, + ...trees.value.map((tree) => tree.state), + ...files.value.map((file) => file.state), + ]; + const leased = acquireLeases(leases, "create_private_root"); + if (!leased.ok) return leased; + + try { + const created = await this.backend.createPrivateRoot({ + parent: parent.value.backend, + prefix, + trees: trees.value.map((tree) => ({ + role: tree.role, + source: tree.backend, + destination: tree.destination, + })), + files: files.value.map((file) => ({ + role: file.role, + source: file.backend, + destination: file.destination, + })), + writableDirectories, + entrypoint, + launch, + limits, + control: controlSnapshot, + }); + if (!created.ok) return created; + const observation = parsePrivateRootObservation( + created.value.observation, + ); + if (!observation.ok) { + await poisonAliasedToken(created.value.resource, () => + created.value.resource.close(), + ); + return observation; + } + const resource = createResource({ + authority: this, + operation: "create_private_root", + kind: "privateRoot", + observation: observation.value, + backend: created.value.resource, + }); + if (!resource.ok) + await poisonAliasedToken(created.value.resource, () => + created.value.resource.close(), + ); + return resource; + } finally { + releaseLeases(leases); + } + } + + /** Spawn a process atomically assigned to a dedicated kill-on-close job. */ + async spawnOwnedProcess( + input: WindowsOwnedProcessInput, + ): Promise> { + const limits = { + stdoutBytes: input.limits.stdoutBytes, + stderrBytes: input.limits.stderrBytes, + }; + const controlSnapshot = control(input); + + const runtimeProjection = ownedResource( + this, + input.runtimeProjection, + "spawn_owned_process", + "privateRoot", + ); + if (!runtimeProjection.ok) return runtimeProjection; + + const leases: ResourceState[] = [runtimeProjection.value.state]; + const leased = acquireLeases(leases, "spawn_owned_process"); + if (!leased.ok) return leased; + + try { + const spawned = await this.backend.spawnOwnedProcess({ + runtimeProjection: runtimeProjection.value.backend, + limits, + control: controlSnapshot, + }); + if (!spawned.ok) return spawned; + const process = createOwnedProcess(this, spawned.value, limits); + if (!process.ok) + await poisonAliasedToken(spawned.value, async () => { + await spawned.value.closeProcess(); + }); + return process; + } finally { + releaseLeases(leases); + } + } +} + +const registerBackend = ( + authority: WindowsNativeAuthority, + kind: ResourceKind, + backend: WindowsNativeBackendResource, + operation: WindowsNativeAuthorityOperation, + closeBackend: ResourceState["closeBackend"], +): Result => { + const existing = backendTokenStates.get(backend); + if (existing !== undefined) { + return err( + new WindowsNativeAuthorityError( + operation, + "native_contract_mismatch", + existing.kind === kind + ? "Windows native backend token is already registered" + : "Windows native backend token is registered with a different kind", + ), + ); + } + + const state: ResourceState = { + owner: authority, + backend, + closeBackend, + kind, + status: "open", + leaseCount: 0, + }; + backendTokenStates.set(backend, state); + return ok(state); +}; + +const poisonAliasedToken = async ( + backend: WindowsNativeBackendResource, + cleanup: () => Promise, +): Promise => { + const existing = backendTokenStates.get(backend); + if (existing !== undefined) { + if (existing.status === "closed") return; + existing.status = "closing"; + if (existing.leaseCount > 0) { + existing.closeDeferred ??= createDeferredClose(); + return; + } + existing.closePromise ??= safeCloseBackend(existing).then((result) => { + existing.status = "closed"; + return result; + }); + await existing.closePromise; + return; + } + try { + await cleanup(); + } catch { + // The contract mismatch remains the caller-visible failure; the token is + // poisoned even if the malformed backend also rejects cleanup. + } finally { + // A fresh malformed token has no wrapper state to retain. + } +}; + +const safeCloseBackend = async ( + state: ResourceState, +): Promise> => { + try { + return await state.closeBackend(); + } catch (cause: unknown) { + return err( + new WindowsNativeAuthorityError( + "close_resource", + "native_failure", + "Windows native resource cleanup failed", + { cause }, + ), + ); + } +}; + +const ownedResource = ( + authority: WindowsNativeAuthority, + resource: object, + operation: WindowsNativeAuthorityOperation, + expectedKind: ResourceKind, +): Result => { + const state = resourceStates.get(resource); + if (state === undefined || state.owner !== authority) + return err( + new WindowsNativeAuthorityError( + operation, + "invalid_input", + "Windows native resource does not belong to this authority", + ), + ); + if (state.status !== "open") + return err( + new WindowsNativeAuthorityError( + operation, + "identity_drift", + "Windows native resource is already closed", + ), + ); + if (state.kind !== expectedKind) + return err( + new WindowsNativeAuthorityError( + operation, + "native_contract_mismatch", + `Windows native resource is not a ${expectedKind}`, + ), + ); + return ok({ backend: state.backend, state }); +}; + +const ownedRuntimeTrees = ( + authority: WindowsNativeAuthority, + trees: readonly WindowsRuntimeTreeProjection[], +): Result => { + const projected: ResolvedTreeProjection[] = []; + for (const tree of trees) { + const source = ownedResource( + authority, + tree.source, + "create_private_root", + "directory", + ); + if (!source.ok) return source; + projected.push({ + role: tree.role, + backend: source.value.backend, + destination: tree.destination, + state: source.value.state, + }); + } + return ok(projected); +}; + +const ownedRuntimeFiles = ( + authority: WindowsNativeAuthority, + files: readonly WindowsRuntimeFileProjection[], +): Result => { + const projected: ResolvedFileProjection[] = []; + for (const file of files) { + const source = ownedResource( + authority, + file.source, + "create_private_root", + "file", + ); + if (!source.ok) return source; + projected.push({ + role: file.role, + backend: source.value.backend, + destination: file.destination, + state: source.value.state, + }); + } + return ok(projected); +}; + +const acquireLeases = ( + states: readonly ResourceState[], + operation: WindowsNativeAuthorityOperation, +): Result => { + const acquired: ResourceState[] = []; + for (const state of states) { + if (state.status !== "open") { + for (const s of acquired) s.leaseCount--; + return err( + new WindowsNativeAuthorityError( + operation, + "identity_drift", + "Windows native resource is already closed", + ), + ); + } + state.leaseCount++; + acquired.push(state); + } + return ok(undefined); +}; + +const releaseLeases = (states: readonly ResourceState[]): void => { + for (const state of states) { + state.leaseCount--; + if (state.leaseCount === 0 && state.status === "closing") { + state.closePromise = safeCloseBackend(state).then((result) => { + state.status = "closed"; + state.closeDeferred?.resolve(result); + return result; + }); + } + } +}; + +/** Construct a fail-closed authority backend when the package is unavailable. */ +export const unavailableWindowsNativeAuthorityBackend = ( + message: string, +): WindowsNativeAuthorityBackend => ({ + identity: null, + admitFile: () => Promise.resolve(unavailable("admit_path", message)), + admitDirectory: () => Promise.resolve(unavailable("admit_path", message)), + createPrivateRoot: () => + Promise.resolve(unavailable("create_private_root", message)), + spawnOwnedProcess: () => + Promise.resolve(unavailable("spawn_owned_process", message)), +}); + +const createDeferredClose = (): CloseDeferred => { + let resolve!: (value: Result) => void; + const promise = new Promise>( + (res) => { + resolve = res; + }, + ); + return { promise, resolve }; +}; + +const closeResource = ( + resource: object, +): Promise> => { + const state = resourceStates.get(resource); + if (state === undefined) + return Promise.resolve( + err( + new WindowsNativeAuthorityError( + "close_resource", + "invalid_input", + "Windows native resource is not owned by REA", + ), + ), + ); + if (state.status === "closed") + return state.closePromise ?? Promise.resolve(ok(undefined)); + if (state.status === "closing") + return ( + state.closeDeferred?.promise ?? + state.closePromise ?? + Promise.resolve(ok(undefined)) + ); + + state.status = "closing"; + if (state.leaseCount === 0) { + state.closePromise = safeCloseBackend(state).then((result) => { + state.status = "closed"; + return result; + }); + return state.closePromise; + } + + const deferred = createDeferredClose(); + state.closeDeferred = deferred; + return deferred.promise; +}; + +const createResource = ( + input: Readonly<{ + authority: WindowsNativeAuthority; + operation: WindowsNativeAuthorityOperation; + kind: Exclude; + observation: TObservation; + backend: WindowsNativeBackendResource; + }>, +): Result< + TObservation & { + close(): Promise>; + }, + WindowsNativeAuthorityError +> => { + const stateResult = registerBackend( + input.authority, + input.kind, + input.backend, + input.operation, + () => input.backend.close(), + ); + if (!stateResult.ok) return stateResult; + const resource = { + ...input.observation, + close: () => closeResource(resource), + } as TObservation & { + close(): Promise>; + }; + resourceStates.set(resource, stateResult.value); + return ok(resource); +}; + +const createOwnedProcess = ( + authority: WindowsNativeAuthority, + backend: WindowsNativeBackendProcess, + limits: WindowsOwnedProcessLimits, +): Result => { + const ownership = jobOwnershipObservationSchema.safeParse(backend.ownership); + if ( + !ownership.success || + !Number.isSafeInteger(backend.processId) || + backend.processId <= 0 + ) + return err( + new WindowsNativeAuthorityError( + "spawn_owned_process", + "native_contract_mismatch", + "Windows native adapter returned an invalid Job Object observation", + ), + ); + const ownershipObservation: WindowsJobOwnershipObservation = { + ...ownership.data, + policySha256: jobOwnershipPolicySha256(ownership.data), + }; + const stateResult = registerBackend( + authority, + "process", + backend, + "spawn_owned_process", + () => + closeOwnedProcess(backend).then((result) => + result.ok ? ok(undefined) : result, + ), + ); + if (!stateResult.ok) return stateResult; + const state = stateResult.value; + + let closePromise: + | Promise> + | undefined; + const process = { + processId: backend.processId, + ownership: ownershipObservation, + snapshot: () => { + try { + return parseProcessSnapshot(backend.snapshot(), limits); + } catch (cause: unknown) { + if (cause instanceof WindowsNativeAuthorityError) throw cause; + throw new WindowsNativeAuthorityError( + "observe_process", + "native_failure", + "Windows native process observation failed", + { cause }, + ); + } + }, + waitForExit: async (signal?: AbortSignal) => { + try { + const result = await backend.waitForExit(signal); + return parseProcessResult( + result, + ownedProcessExitSchema, + "wait_for_exit", + ); + } catch (cause: unknown) { + return nativeProcessFailure( + "wait_for_exit", + "Windows native process wait failed", + cause, + ); + } + }, + close: () => { + if (closePromise !== undefined) return closePromise; + state.status = "closing"; + closePromise = closeOwnedProcess(backend).then((result) => { + state.status = "closed"; + return result; + }); + return closePromise; + }, + }; + resourceStates.set(process, state); + return ok(process); +}; + +const parseProcessSnapshot = ( + value: unknown, + limits: WindowsOwnedProcessLimits, +): WindowsOwnedProcessSnapshot => { + const parsed = ownedProcessSnapshotSchema.safeParse(value); + if ( + !parsed.success || + !validStreamSnapshot(parsed.data.stdout, limits.stdoutBytes) || + !validStreamSnapshot(parsed.data.stderr, limits.stderrBytes) + ) + throw new WindowsNativeAuthorityError( + "observe_process", + "native_contract_mismatch", + "Windows native adapter returned an invalid process snapshot", + ); + return parsed.data; +}; + +const validStreamSnapshot = ( + stream: WindowsOwnedProcessStreamSnapshot, + retainedLimit: number, +): boolean => + Number.isSafeInteger(stream.totalBytes) && + stream.retained.byteLength <= retainedLimit && + stream.retained.byteLength <= stream.totalBytes && + stream.truncated === stream.totalBytes > stream.retained.byteLength; + +const parseProcessResult = ( + value: unknown, + successSchema: z.ZodType, + operation: "wait_for_exit" | "close_resource", +): Result => { + if (typeof value !== "object" || value === null || !("ok" in value)) + return malformedProcessResult(operation); + const candidate = value as Readonly>; + if (candidate.ok === true) { + if (Object.keys(candidate).length !== 2 || !("value" in candidate)) + return malformedProcessResult(operation); + const parsed = successSchema.safeParse(candidate.value); + return parsed.success ? ok(parsed.data) : malformedProcessResult(operation); + } + if ( + candidate.ok !== false || + Object.keys(candidate).length !== 2 || + !(candidate.error instanceof WindowsNativeAuthorityError) + ) + return malformedProcessResult(operation); + return err(candidate.error); +}; + +const malformedProcessResult = ( + operation: "wait_for_exit" | "close_resource", +): Result => + err( + new WindowsNativeAuthorityError( + operation, + "native_contract_mismatch", + "Windows native adapter returned an invalid process result", + ), + ); + +const nativeProcessFailure = ( + operation: "wait_for_exit" | "close_resource", + message: string, + cause: unknown, +): Result => + err( + new WindowsNativeAuthorityError(operation, "native_failure", message, { + cause, + }), + ); + +const closeOwnedProcess = async ( + backend: WindowsNativeBackendProcess, +): Promise> => { + try { + return parseProcessResult( + await backend.closeProcess(), + ownedProcessCloseSchema, + "close_resource", + ); + } catch (cause: unknown) { + return nativeProcessFailure( + "close_resource", + "Windows native process cleanup failed", + cause, + ); + } +}; + +const control = ( + input: WindowsNativeOperationControl, +): WindowsNativeOperationControl => ({ + deadlineMs: input.deadlineMs, + ...(input.signal === undefined ? {} : { signal: input.signal }), +}); + +const unavailable = ( + operation: WindowsNativeAuthorityOperation, + message: string, +): Result => + err(new WindowsNativeAuthorityError(operation, "unavailable", message)); diff --git a/src/application/WindowsNativeAuthorityLoader.ts b/src/application/WindowsNativeAuthorityLoader.ts new file mode 100644 index 000000000..fe955ae50 --- /dev/null +++ b/src/application/WindowsNativeAuthorityLoader.ts @@ -0,0 +1,401 @@ +import { createHash } from "node:crypto"; +import { readFile, realpath } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { win32 } from "node:path"; + +import { z } from "zod"; + +import { PACKAGE_METADATA } from "../generatedPackageMetadata.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + WindowsNativeAuthorityError, + type WindowsNativeAuthorityBackend, + type WindowsNativeAuthorityIdentity, +} from "./WindowsNativeAuthority.js"; + +const BACKEND_EXPORTS = [ + "admitDirectory", + "admitFile", + "createPrivateRoot", + "identity", + "spawnOwnedProcess", +] as const; + +const identitySchema = z.strictObject({ + packageName: z.string().min(1), + packageVersion: z.string().min(1), + contractVersion: z.number().int().positive(), + nodeApiVersion: z.number().int().positive(), + artifactSha256: z.string().regex(/^[0-9a-f]{64}$/u), +}); + +const packageManifestSchema = z.object({ + name: z.string().min(1), + version: z.string().min(1), + os: z.tuple([z.literal("win32")]), + cpu: z.tuple([z.literal("x64")]), + scripts: z.undefined().optional(), + reaNativeAuthority: z.strictObject({ + contractVersion: z.number().int().positive(), + nodeApiVersion: z.number().int().positive(), + artifact: z.string().regex(/^[^\\/]+\.node$/u), + artifactSha256: z.string().regex(/^[0-9a-f]{64}$/u), + exports: z.tuple([ + z.literal("admitDirectory"), + z.literal("admitFile"), + z.literal("createPrivateRoot"), + z.literal("identity"), + z.literal("spawnOwnedProcess"), + ]), + }), +}); + +/** Release-generated identity required before the Windows addon can load. */ +export interface WindowsNativeAuthorityPackageCommitment { + readonly packageName: string; + readonly packageVersion: string; + readonly contractVersion: number; + readonly nodeApiVersion: number; + readonly artifactSha256: string | null; +} + +/** Injectable Node package-loading seam used by the production loader. */ +export interface WindowsNativeAuthorityLoadHost { + readonly platform: NodeJS.Platform; + readonly architecture: string; + readonly nodeApiVersion: number | null; + resolve(packageName: string): string; + resolveManifest(packageName: string): string; + canonicalize(path: string): Promise; + read(path: string): Promise; + cached(path: string): unknown | undefined; + load(path: string): unknown; +} + +interface LoadedBackend { + readonly backend: LoadedWindowsNativeBackend; + readonly canonicalPath: string; + readonly identity: WindowsNativeAuthorityIdentity; +} + +type LoadedWindowsNativeBackend = WindowsNativeAuthorityBackend & { + readonly identity: WindowsNativeAuthorityIdentity; +}; + +const require = createRequire(import.meta.url); + +/** Production host backed by Node's exact CommonJS/native-addon loader. */ +export const systemWindowsNativeAuthorityLoadHost = + (): WindowsNativeAuthorityLoadHost => ({ + platform: process.platform, + architecture: process.arch, + nodeApiVersion: parseNodeApiVersion(process.versions.napi), + resolve: (packageName) => require.resolve(packageName), + resolveManifest: (packageName) => + require.resolve(`${packageName}/package.json`), + canonicalize: realpath, + read: readFile, + cached: (path) => require.cache[path]?.exports, + load: (path) => require(path), + }); + +/** Stateful process-lifetime loader for one pinned native module identity. */ +export class WindowsNativeAuthorityPackageLoader { + private loaded: LoadedBackend | undefined; + private loading: + | Promise< + Result + > + | undefined; + + constructor( + private readonly host: WindowsNativeAuthorityLoadHost, + private readonly commitment: WindowsNativeAuthorityPackageCommitment, + ) {} + + /** Load once and pin either the backend or its failure for this process. */ + load(): Promise< + Result + > { + if (this.loaded !== undefined) + return Promise.resolve(ok(this.loaded.backend)); + this.loading ??= this.loadOnce(); + return this.loading; + } + + private async loadOnce(): Promise< + Result + > { + const supported = this.checkRuntime(); + if (!supported.ok) return supported; + const manifest = await this.readManifest(); + if (!manifest.ok) return manifest; + let resolvedPath: string; + try { + resolvedPath = await this.host.canonicalize( + this.host.resolve(this.commitment.packageName), + ); + } catch (cause: unknown) { + return err( + loadError( + "unavailable", + "Windows native authority package is not installed or resolvable", + cause, + ), + ); + } + let expectedArtifactPath: string; + try { + expectedArtifactPath = await this.host.canonicalize( + win32.resolve( + win32.dirname(manifest.value.path), + manifest.value.artifact, + ), + ); + } catch (cause: unknown) { + return err( + loadError( + "unavailable", + "Windows native authority declared addon is unavailable", + cause, + ), + ); + } + if (resolvedPath !== expectedArtifactPath) + return err( + loadError( + "native_contract_mismatch", + "Windows native authority package resolved an unexpected addon path", + ), + ); + if (this.host.cached(resolvedPath) !== undefined) + return err( + loadError( + "native_contract_mismatch", + "Windows native authority addon was cached before identity verification", + ), + ); + let artifact: Uint8Array; + try { + artifact = await this.host.read(resolvedPath); + } catch (cause: unknown) { + return err( + loadError( + "unavailable", + "Windows native authority addon could not be read", + cause, + ), + ); + } + const digest = createHash("sha256").update(artifact).digest("hex"); + if (digest !== this.commitment.artifactSha256) + return err( + loadError( + "identity_drift", + "Windows native authority addon digest does not match this REA release", + ), + ); + let module: unknown; + try { + module = this.host.load(resolvedPath); + } catch (cause: unknown) { + return err( + loadError( + "unavailable", + "Windows native authority addon could not be loaded", + cause, + ), + ); + } + const parsed = parseBackend(module, this.commitment, digest); + if (!parsed.ok) return parsed; + this.loaded = { + backend: parsed.value, + canonicalPath: resolvedPath, + identity: parsed.value.identity, + }; + return ok(parsed.value); + } + + private checkRuntime(): Result { + if (this.host.platform !== "win32" || this.host.architecture !== "x64") + return err( + loadError( + "unavailable", + "Windows native authority requires Windows x64", + ), + ); + if (this.commitment.artifactSha256 === null) + return err( + loadError( + "unavailable", + "This REA build has no committed Windows native authority artifact", + ), + ); + if ( + this.host.nodeApiVersion === null || + this.host.nodeApiVersion < this.commitment.nodeApiVersion + ) + return err( + loadError( + "unavailable", + "Windows native authority requires the committed Node-API version", + ), + ); + return ok(undefined); + } + + private async readManifest(): Promise< + Result< + { readonly path: string; readonly artifact: string }, + WindowsNativeAuthorityError + > + > { + let path: string; + let bytes: Uint8Array; + try { + path = await this.host.canonicalize( + this.host.resolveManifest(this.commitment.packageName), + ); + bytes = await this.host.read(path); + } catch (cause: unknown) { + return err( + loadError( + "unavailable", + "Windows native authority package manifest is unavailable", + cause, + ), + ); + } + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(bytes).toString("utf8")); + } catch (cause: unknown) { + return err( + loadError( + "native_contract_mismatch", + "Windows native authority package manifest is not valid JSON", + cause, + ), + ); + } + const parsed = packageManifestSchema.safeParse(decoded); + if ( + !parsed.success || + parsed.data.name !== this.commitment.packageName || + parsed.data.version !== this.commitment.packageVersion || + parsed.data.reaNativeAuthority.contractVersion !== + this.commitment.contractVersion || + parsed.data.reaNativeAuthority.nodeApiVersion !== + this.commitment.nodeApiVersion || + parsed.data.reaNativeAuthority.artifactSha256 !== + this.commitment.artifactSha256 + ) + return err( + loadError( + "native_contract_mismatch", + "Windows native authority package manifest does not match this REA release", + ), + ); + return ok({ + path, + artifact: parsed.data.reaNativeAuthority.artifact, + }); + } +} + +let productionLoader: WindowsNativeAuthorityPackageLoader | undefined; + +/** Return the one process-global loader for release-generated package metadata. */ +export const createWindowsNativeAuthorityPackageLoader = + (): WindowsNativeAuthorityPackageLoader => { + productionLoader ??= new WindowsNativeAuthorityPackageLoader( + systemWindowsNativeAuthorityLoadHost(), + PACKAGE_METADATA.windowsNativeAuthority, + ); + return productionLoader; + }; + +const parseBackend = ( + input: unknown, + commitment: WindowsNativeAuthorityPackageCommitment, + digest: string, +): Result => { + if (typeof input !== "object" || input === null) + return err( + loadError( + "native_contract_mismatch", + "Windows native authority addon exported a non-object contract", + ), + ); + const keys = Object.keys(input).sort((left, right) => + left.localeCompare(right), + ); + if (!sameStrings(keys, BACKEND_EXPORTS)) + return err( + loadError( + "native_contract_mismatch", + "Windows native authority addon export set does not match the contract", + ), + ); + const candidate = input as Record; + const identity = identitySchema.safeParse(candidate.identity); + if (!identity.success || !sameIdentity(identity.data, commitment, digest)) + return err( + loadError( + "native_contract_mismatch", + "Windows native authority addon identity does not match this REA release", + ), + ); + if ( + typeof candidate.admitFile !== "function" || + typeof candidate.admitDirectory !== "function" || + typeof candidate.createPrivateRoot !== "function" || + typeof candidate.spawnOwnedProcess !== "function" + ) + return err( + loadError( + "native_contract_mismatch", + "Windows native authority addon operations do not match the contract", + ), + ); + // SAFETY: the exact export set, identity, and every callable backend operation + // were checked above. Operation results remain parsed by WindowsNativeAuthority. + return ok(candidate as unknown as LoadedWindowsNativeBackend); +}; + +const sameIdentity = ( + identity: WindowsNativeAuthorityIdentity, + commitment: WindowsNativeAuthorityPackageCommitment, + digest: string, +): boolean => + identity.packageName === commitment.packageName && + identity.packageVersion === commitment.packageVersion && + identity.contractVersion === commitment.contractVersion && + identity.nodeApiVersion === commitment.nodeApiVersion && + identity.artifactSha256 === digest; + +const sameStrings = ( + actual: readonly string[], + expected: readonly string[], +): boolean => + actual.length === expected.length && + actual.every((value, index) => value === expected[index]); + +const parseNodeApiVersion = (value: string | undefined): number | null => { + if (value === undefined || !/^\d+$/u.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +}; + +const loadError = ( + reason: "unavailable" | "identity_drift" | "native_contract_mismatch", + message: string, + cause?: unknown, +): WindowsNativeAuthorityError => + new WindowsNativeAuthorityError( + "load_native_authority", + reason, + message, + cause === undefined ? undefined : { cause }, + ); diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index b3e9df193..faf0fec77 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -5,5 +5,12 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.0.0-beta.4", clientSdkVersion: "2.0.0-beta.4", coreSdkVersion: "2.0.0-beta.4", - skillVersion: "21", + skillVersion: "22", + windowsNativeAuthority: { + packageName: "@rea/windows-x64", + packageVersion: "2.0.0", + contractVersion: 1, + nodeApiVersion: 9, + artifactSha256: null, + }, } as const; diff --git a/src/process/PrivateRuntimeRoot.ts b/src/process/PrivateRuntimeRoot.ts index 92ba3de08..552d18c4c 100644 --- a/src/process/PrivateRuntimeRoot.ts +++ b/src/process/PrivateRuntimeRoot.ts @@ -9,10 +9,11 @@ export interface PrivateRuntimeRootOptions { } /** - * Owns one mode-0700 temporary runtime root and removes it idempotently. + * Owns one POSIX mode-0700 temporary runtime root and removes it idempotently. * * Protocol adapters decide what belongs inside the directory; this primitive - * only establishes a private filesystem boundary and deterministic cleanup. + * establishes a private filesystem boundary on POSIX and deterministic cleanup. + * Windows privacy requires the separately verified native DACL boundary. */ export class PrivateRuntimeRoot { #closePromise: Promise | undefined; diff --git a/tests/windowsCapabilities.test.ts b/tests/windowsCapabilities.test.ts index 456bd7e95..a6fd0675a 100644 --- a/tests/windowsCapabilities.test.ts +++ b/tests/windowsCapabilities.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { probeWindowsCapabilities, + systemNoFollowOpenCapability, type WindowsCapabilityDependencies, } from "../src/application/WindowsCapabilities.js"; @@ -20,6 +21,10 @@ const dependencies = ( available: false, reason: "Windows ACL enforcement is not implemented by this REA build", }), + probeNativeAuthority: () => ({ + available: false, + reason: "Windows native authority package is not installed", + }), probeUnixDomainSocket: () => ({ available: false, reason: "Node path-based IPC uses named pipes on Windows", @@ -34,6 +39,17 @@ const dependencies = ( }); describe("Windows host capability report", () => { + it("never treats a numeric POSIX flag as Windows reparse authority", () => { + expect(systemNoFollowOpenCapability("win32", 0x20_000)).toEqual({ + available: false, + reason: "Windows reparse-safe handle admission is not implemented", + }); + expect(systemNoFollowOpenCapability("linux", 0x20_000)).toEqual({ + available: true, + reason: null, + }); + }); + it("keeps unavailable security controls explicit", async () => { await expect(probeWindowsCapabilities(dependencies())).resolves.toEqual({ platform: "win32", @@ -49,6 +65,10 @@ describe("Windows host capability report", () => { reason: "Windows ACL enforcement is not implemented by this REA build", }, + native_authority: { + available: false, + reason: "Windows native authority package is not installed", + }, unix_domain_socket: { available: false, reason: "Node path-based IPC uses named pipes on Windows", diff --git a/tests/windowsNativeAuthority.test.ts b/tests/windowsNativeAuthority.test.ts new file mode 100644 index 000000000..356faf2a7 --- /dev/null +++ b/tests/windowsNativeAuthority.test.ts @@ -0,0 +1,1194 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it, vi } from "vitest"; + +import { + WindowsNativeAuthority, + WindowsNativeAuthorityError, + unavailableWindowsNativeAuthorityBackend, + type WindowsNativeAuthorityBackend, + type WindowsNativeBackendProcess, + type WindowsNativeBackendResource, + type WindowsNativeOperationControl, + type WindowsOwnedProcessSnapshot, +} from "../src/application/WindowsNativeAuthority.js"; +import { err, ok } from "../src/domain/result.js"; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +const identity = { + packageName: "@rea/windows-x64", + packageVersion: "1.0.0", + contractVersion: 1, + nodeApiVersion: 9, + artifactSha256: "a".repeat(64), +}; + +const pathIdentity = (requestedPath: string, fileIdentity: string) => ({ + requestedPath, + finalPath: requestedPath, + filesystem: "ntfs" as const, + volumeIdentity: "0123456789abcdef", + fileIdentity, + reparseDisposition: "absent" as const, +}); + +const privateDacl = (input: { + readonly objectKind: "directory" | "file"; + readonly protection: "protected" | "inherited_from_protected_parent"; + readonly aceDisposition: "explicit" | "inherited"; + readonly propagation: "none" | "container_and_object"; + readonly currentUserRights: "read_execute" | "modify"; + readonly verifiedObjectCount: number; + readonly digestByte: string; +}) => ({ + objectKind: input.objectKind, + owner: "current_user" as const, + dacl: "present" as const, + protection: input.protection, + aceDisposition: input.aceDisposition, + propagation: input.propagation, + additionalTrustees: "absent" as const, + broadAllowEntries: "absent" as const, + currentUserRights: input.currentUserRights, + systemRights: "full_control" as const, + ownerRights: "write_dac_write_owner_denied" as const, + descriptorSha256: input.digestByte.repeat(64), + verifiedObjectCount: input.verifiedObjectCount, + policyManifestSha256: input.digestByte.repeat(64), +}); + +const privateRootObservation = (canonicalPath: string) => { + const manifestSha256 = "c".repeat(64); + const launchSha256 = "e".repeat(64); + const policies = { + rootDirectory: privateDacl({ + objectKind: "directory", + protection: "protected", + aceDisposition: "explicit", + propagation: "none", + currentUserRights: "read_execute", + verifiedObjectCount: 1, + digestByte: "1", + }), + readonlyDirectories: privateDacl({ + objectKind: "directory", + protection: "protected", + aceDisposition: "explicit", + propagation: "container_and_object", + currentUserRights: "read_execute", + verifiedObjectCount: 20, + digestByte: "2", + }), + readonlyFiles: privateDacl({ + objectKind: "file", + protection: "protected", + aceDisposition: "explicit", + propagation: "none", + currentUserRights: "read_execute", + verifiedObjectCount: 100, + digestByte: "3", + }), + writableDirectories: privateDacl({ + objectKind: "directory", + protection: "protected", + aceDisposition: "explicit", + propagation: "container_and_object", + currentUserRights: "modify", + verifiedObjectCount: 3, + digestByte: "4", + }), + writableFiles: privateDacl({ + objectKind: "file", + protection: "inherited_from_protected_parent", + aceDisposition: "inherited", + propagation: "none", + currentUserRights: "modify", + verifiedObjectCount: 0, + digestByte: "5", + }), + }; + const commitment = { + version: 1, + projectionManifestSha256: manifestSha256, + launchSha256, + ...Object.fromEntries( + Object.entries(policies).map(([name, policy]) => [ + name, + { + verifiedObjectCount: policy.verifiedObjectCount, + policyManifestSha256: policy.policyManifestSha256, + }, + ]), + ), + }; + return { + canonicalPath, + stableIdentity: "root:1", + state: "sealed" as const, + manifestSha256, + launchSha256, + fileCount: 100, + directoryCount: 24, + totalBytes: 1_000, + entrypoint: { + relativePath: "jdk\\bin\\java.exe", + sha256: "d".repeat(64), + stableIdentity: "projected-file:1", + }, + security: { + manifestSha256: createHash("sha256") + .update(JSON.stringify(commitment)) + .digest("hex"), + ...policies, + }, + }; +}; + +const privateRootPolicyOverride = ( + policyName: + | "rootDirectory" + | "readonlyDirectories" + | "readonlyFiles" + | "writableDirectories" + | "writableFiles", + override: Readonly>, +) => { + const observation = privateRootObservation("C:\\runtime\\rea-1"); + const policy = observation.security[policyName]; + return { + ...observation, + security: { + ...observation.security, + [policyName]: { ...policy, ...override }, + }, + }; +}; + +class FakeResource implements WindowsNativeBackendResource { + readonly close = vi.fn(() => Promise.resolve(ok(undefined))); +} + +const jobOwnership = { + jobObject: "dedicated_unnamed", + assignment: "proc_thread_attribute_job_list", + processExecution: "job_assigned_at_creation", + killOnJobClose: "enabled", + breakaway: "disabled", + nestedJob: "absent", + ownerHandle: "retained_non_inheritable", + jobHandleOwnership: "single_rea_handle", + inheritedHandles: "stdio_only", + membership: "verified", +}; + +const jobOwnershipObservation = { + ...jobOwnership, + policySha256: createHash("sha256") + .update(JSON.stringify({ version: 1, ...jobOwnership })) + .digest("hex"), +}; + +class FakeProcess extends FakeResource implements WindowsNativeBackendProcess { + readonly processId = 42; + constructor(readonly ownership: unknown = jobOwnership) { + super(); + } + readonly closeProcess = vi.fn(() => + Promise.resolve(ok({ status: "terminated" as const })), + ); + readonly waitForExit = vi.fn(() => + Promise.resolve(ok({ status: "exited" as const, exitCode: 0 })), + ); + snapshot(): WindowsOwnedProcessSnapshot { + return { + stdout: { + retained: new Uint8Array([2, 3]), + totalBytes: 3, + truncated: true, + }, + stderr: { + retained: new Uint8Array(), + totalBytes: 0, + truncated: false, + }, + exit: null, + }; + } +} + +const recordingBackend = () => { + const fileResource = new FakeResource(); + const directoryResource = new FakeResource(); + const rootResource = new FakeResource(); + const process = new FakeProcess(); + const backend: WindowsNativeAuthorityBackend = { + identity, + admitFile: vi.fn((input) => + Promise.resolve( + ok({ + resource: fileResource, + observation: { + kind: "file", + ...pathIdentity(input.path, "00112233445566778899aabbccddeeff"), + sha256: "b".repeat(64), + }, + }), + ), + ), + admitDirectory: vi.fn( + (input) => + Promise.resolve( + ok({ + resource: directoryResource, + observation: { + kind: "directory", + ...pathIdentity(input.path, "3".repeat(32)), + sha256: null, + }, + }), + ), + ), + createPrivateRoot: vi.fn< + WindowsNativeAuthorityBackend["createPrivateRoot"] + >((input) => + Promise.resolve( + ok({ + resource: rootResource, + observation: privateRootObservation(`C:\\runtime\\${input.prefix}1`), + }), + ), + ), + spawnOwnedProcess: vi.fn(() => Promise.resolve(ok(new FakeProcess()))), + }; + vi.mocked(backend.spawnOwnedProcess).mockResolvedValueOnce(ok(process)); + return { backend, fileResource, directoryResource, rootResource, process }; +}; + +const control: WindowsNativeOperationControl = { deadlineMs: 10_000 }; + +describe("Windows native authority port", () => { + it("fails closed when the optional adapter is unavailable", async () => { + const authority = new WindowsNativeAuthority( + unavailableWindowsNativeAuthorityBackend("package is not installed"), + ); + expect(authority.identity).toBeNull(); + await expect( + authority.admitPath({ + ...control, + path: "C:\\rea\\provider.exe", + kind: "file", + reparsePolicy: "reject_all", + }), + ).resolves.toMatchObject({ + ok: false, + error: { operation: "admit_path", reason: "unavailable" }, + }); + }); + + it("parses stable NTFS identity and closes malformed native observations", async () => { + const recorded = recordingBackend(); + const authority = new WindowsNativeAuthority(recorded.backend); + const admitted = await authority.admitPath({ + ...control, + path: "C:\\rea\\provider.exe", + kind: "file", + reparsePolicy: "reject_all", + }); + expect(admitted).toMatchObject({ + ok: true, + value: { + requestedPath: "C:\\rea\\provider.exe", + finalPath: "C:\\rea\\provider.exe", + filesystem: "ntfs", + volumeIdentity: "0123456789abcdef", + fileIdentity: "00112233445566778899aabbccddeeff", + stableIdentity: + "ntfs:0123456789abcdef:00112233445566778899aabbccddeeff", + }, + }); + + const malformed = recordingBackend(); + vi.mocked(malformed.backend.admitFile).mockResolvedValueOnce( + ok({ + resource: malformed.fileResource, + observation: { + kind: "file", + ...pathIdentity("C:\\other\\provider.exe", "2".repeat(32)), + sha256: "b".repeat(64), + }, + }), + ); + await expect( + new WindowsNativeAuthority(malformed.backend).admitPath({ + ...control, + path: "C:\\rea\\provider.exe", + kind: "file", + reparsePolicy: "reject_all", + }), + ).resolves.toMatchObject({ + ok: false, + error: { + operation: "admit_path", + reason: "native_contract_mismatch", + }, + }); + expect(malformed.fileResource.close).toHaveBeenCalledOnce(); + }); + + it.each([ + [ + "uppercase identity", + { + kind: "file", + ...pathIdentity("C:\\rea\\provider.exe", "A".repeat(32)), + sha256: "b".repeat(64), + }, + ], + [ + "unsupported filesystem", + { + kind: "file", + ...pathIdentity( + "C:\\rea\\provider.exe", + "00112233445566778899aabbccddeeff", + ), + filesystem: "refs", + sha256: "b".repeat(64), + }, + ], + [ + "unknown field", + { + kind: "file", + ...pathIdentity( + "C:\\rea\\provider.exe", + "00112233445566778899aabbccddeeff", + ), + sha256: "b".repeat(64), + unexpected: true, + }, + ], + ])("rejects a native observation with %s", async (_name, observation) => { + const recorded = recordingBackend(); + vi.mocked(recorded.backend.admitFile).mockResolvedValueOnce( + ok({ resource: recorded.fileResource, observation }), + ); + await expect( + new WindowsNativeAuthority(recorded.backend).admitPath({ + ...control, + path: "C:\\rea\\provider.exe", + kind: "file", + reparsePolicy: "reject_all", + }), + ).resolves.toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + expect(recorded.fileResource.close).toHaveBeenCalledOnce(); + }); + + it.each([ + ["null DACL", privateRootPolicyOverride("rootDirectory", { dacl: "null" })], + [ + "broad allow entry", + privateRootPolicyOverride("readonlyFiles", { + broadAllowEntries: "present", + }), + ], + [ + "non-modifiable writable policy", + privateRootPolicyOverride("writableDirectories", { + currentUserRights: "read_execute", + }), + ], + [ + "wrong owner", + privateRootPolicyOverride("rootDirectory", { owner: "administrators" }), + ], + [ + "unprotected immutable file", + privateRootPolicyOverride("readonlyFiles", { + protection: "inherited_from_protected_parent", + }), + ], + [ + "missing OWNER RIGHTS denial", + privateRootPolicyOverride("readonlyDirectories", { + ownerRights: "absent", + }), + ], + [ + "additional trustee", + privateRootPolicyOverride("writableFiles", { + additionalTrustees: "present", + }), + ], + [ + "wrong SYSTEM rights", + privateRootPolicyOverride("rootDirectory", { systemRights: "modify" }), + ], + [ + "leaf propagation", + privateRootPolicyOverride("readonlyFiles", { + propagation: "container_and_object", + }), + ], + [ + "unknown policy field", + privateRootPolicyOverride("rootDirectory", { unexpected: true }), + ], + [ + "malformed descriptor digest", + privateRootPolicyOverride("rootDirectory", { descriptorSha256: "abc" }), + ], + [ + "mismatched object count", + { + ...privateRootObservation("C:\\runtime\\rea-1"), + fileCount: 101, + }, + ], + [ + "mismatched security manifest", + { + ...privateRootObservation("C:\\runtime\\rea-1"), + security: { + ...privateRootObservation("C:\\runtime\\rea-1").security, + manifestSha256: "0".repeat(64), + }, + }, + ], + [ + "security manifest from another projection", + { + ...privateRootObservation("C:\\runtime\\rea-1"), + manifestSha256: "a".repeat(64), + }, + ], + ])( + "rejects a private-root observation with %s", + async (_name, observation) => { + const recorded = recordingBackend(); + const authority = new WindowsNativeAuthority(recorded.backend); + const directory = await authority.admitPath({ + ...control, + path: "C:\\rea", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!directory.ok) throw new Error("fake directory admission failed"); + vi.mocked(recorded.backend.createPrivateRoot).mockResolvedValueOnce( + ok({ resource: recorded.rootResource, observation }), + ); + await expect( + authority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees: [], + files: [], + writableDirectories: [], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: [], environment: {} }, + limits: { entries: 1, depth: 1, bytes: 1 }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { + operation: "create_private_root", + reason: "native_contract_mismatch", + }, + }); + expect(recorded.rootResource.close).toHaveBeenCalledOnce(); + }, + ); + + it("scopes resources, deadlines, and process lifetime to one authority", async () => { + const recorded = recordingBackend(); + const authority = new WindowsNativeAuthority(recorded.backend); + const foreignAuthority = new WindowsNativeAuthority( + recordingBackend().backend, + ); + const file = await authority.admitPath({ + ...control, + path: "C:\\rea\\provider.exe", + kind: "file", + reparsePolicy: "reject_all", + }); + const directory = await authority.admitPath({ + ...control, + path: "C:\\rea", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!file.ok || !directory.ok) throw new Error("fake admission failed"); + const foreignDirectory = await foreignAuthority.admitPath({ + ...control, + path: "C:\\foreign-jdk", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!foreignDirectory.ok) throw new Error("fake foreign admission failed"); + + await expect( + foreignAuthority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees: [], + files: [], + writableDirectories: [], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: [], environment: {} }, + limits: { entries: 1, depth: 1, bytes: 1 }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { operation: "create_private_root", reason: "invalid_input" }, + }); + await expect( + authority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees: [ + { + role: "jdk", + source: foreignDirectory.value, + destination: "jdk", + }, + ], + files: [], + writableDirectories: [], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: [], environment: {} }, + limits: { entries: 1, depth: 1, bytes: 1 }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { operation: "create_private_root", reason: "invalid_input" }, + }); + + const root = await authority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees: [ + { + role: "jdk", + source: directory.value, + destination: "jdk", + }, + ], + files: [ + { + role: "target", + source: file.value, + destination: "in\\target.exe", + }, + ], + writableDirectories: ["home", "tmp"], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: ["-version"], environment: {} }, + limits: { entries: 1_000, depth: 20, bytes: 1_000_000 }, + }); + if (!root.ok) throw new Error("fake root creation failed"); + expect(root.value.security).toMatchObject({ + rootDirectory: { + dacl: "present", + protection: "protected", + aceDisposition: "explicit", + propagation: "none", + currentUserRights: "read_execute", + ownerRights: "write_dac_write_owner_denied", + }, + readonlyDirectories: { currentUserRights: "read_execute" }, + readonlyFiles: { currentUserRights: "read_execute" }, + writableDirectories: { currentUserRights: "modify" }, + writableFiles: { + protection: "inherited_from_protected_parent", + aceDisposition: "inherited", + currentUserRights: "modify", + }, + }); + const spawned = await authority.spawnOwnedProcess({ + ...control, + runtimeProjection: root.value, + limits: { stdoutBytes: 2, stderrBytes: 2 }, + }); + if (!spawned.ok) throw new Error("fake process spawn failed"); + expect(spawned.value.ownership).toEqual(jobOwnershipObservation); + + expect(recorded.backend.createPrivateRoot).toHaveBeenCalledWith({ + parent: recorded.directoryResource, + prefix: "rea-", + trees: [ + { + role: "jdk", + source: recorded.directoryResource, + destination: "jdk", + }, + ], + files: [ + { + role: "target", + source: recorded.fileResource, + destination: "in\\target.exe", + }, + ], + writableDirectories: ["home", "tmp"], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: ["-version"], environment: {} }, + limits: { entries: 1_000, depth: 20, bytes: 1_000_000 }, + control, + }); + expect(spawned.value.snapshot().stdout).toMatchObject({ + totalBytes: 3, + truncated: true, + }); + vi.spyOn(recorded.process, "snapshot").mockReturnValueOnce({ + stdout: { retained: new Uint8Array(3), totalBytes: 3, truncated: false }, + stderr: { retained: new Uint8Array(), totalBytes: 0, truncated: false }, + exit: null, + }); + expect(() => spawned.value.snapshot()).toThrowError( + expect.objectContaining({ + operation: "observe_process", + reason: "native_contract_mismatch", + }), + ); + recorded.process.waitForExit.mockResolvedValueOnce( + ok({ status: "exited", exitCode: 1.5 }) as never, + ); + await expect(spawned.value.waitForExit()).resolves.toMatchObject({ + ok: false, + error: { + operation: "wait_for_exit", + reason: "native_contract_mismatch", + }, + }); + recorded.process.waitForExit.mockRejectedValueOnce( + new Error("native wait rejection"), + ); + await expect(spawned.value.waitForExit()).resolves.toMatchObject({ + ok: false, + error: { operation: "wait_for_exit", reason: "native_failure" }, + }); + recorded.process.closeProcess.mockRejectedValueOnce( + new Error("native close rejection"), + ); + const firstClose = await spawned.value.close(); + const secondClose = await spawned.value.close(); + expect(firstClose).toMatchObject({ + ok: false, + error: { operation: "close_resource", reason: "native_failure" }, + }); + expect(secondClose).toBe(firstClose); + expect(recorded.process.closeProcess).toHaveBeenCalledTimes(1); + + await file.value.close(); + await file.value.close(); + expect(recorded.fileResource.close).toHaveBeenCalledTimes(1); + + const projectedSpawn = await authority.spawnOwnedProcess({ + ...control, + runtimeProjection: root.value, + limits: { stdoutBytes: 2, stderrBytes: 2 }, + }); + expect(projectedSpawn.ok).toBe(true); + + await root.value.close(); + await root.value.close(); + expect(recorded.rootResource.close).toHaveBeenCalledTimes(1); + await expect( + authority.spawnOwnedProcess({ + ...control, + runtimeProjection: root.value, + limits: { stdoutBytes: 2, stderrBytes: 2 }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { operation: "spawn_owned_process", reason: "identity_drift" }, + }); + }); + + it.each([ + ["named Job", { ...jobOwnership, jobObject: "named" }], + [ + "post-create assignment", + { ...jobOwnership, assignment: "assign_after_create" }, + ], + [ + "pre-assignment execution", + { ...jobOwnership, processExecution: "started_unassigned" }, + ], + ["disabled kill-on-close", { ...jobOwnership, killOnJobClose: "disabled" }], + ["breakaway", { ...jobOwnership, breakaway: "enabled" }], + ["nested Job", { ...jobOwnership, nestedJob: "compatible" }], + [ + "inheritable owner handle", + { ...jobOwnership, ownerHandle: "inheritable" }, + ], + [ + "duplicate Job handle", + { ...jobOwnership, jobHandleOwnership: "multiple_handles" }, + ], + [ + "ambient inherited handles", + { ...jobOwnership, inheritedHandles: "ambient" }, + ], + ["unverified membership", { ...jobOwnership, membership: "assumed" }], + ["unknown policy field", { ...jobOwnership, unexpected: true }], + ])("rejects a process with %s", async (_name, malformedOwnership) => { + const recorded = recordingBackend(); + const authority = new WindowsNativeAuthority(recorded.backend); + const directory = await authority.admitPath({ + ...control, + path: "C:\\rea", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!directory.ok) throw new Error("fake directory admission failed"); + const root = await authority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees: [], + files: [], + writableDirectories: [], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: [], environment: {} }, + limits: { entries: 1, depth: 1, bytes: 1 }, + }); + if (!root.ok) throw new Error("fake root creation failed"); + const malformed = new FakeProcess(malformedOwnership); + vi.mocked(recorded.backend.spawnOwnedProcess).mockReset(); + vi.mocked(recorded.backend.spawnOwnedProcess).mockResolvedValueOnce( + ok(malformed), + ); + await expect( + authority.spawnOwnedProcess({ + ...control, + runtimeProjection: root.value, + limits: { stdoutBytes: 1, stderrBytes: 1 }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { + operation: "spawn_owned_process", + reason: "native_contract_mismatch", + }, + }); + expect(malformed.closeProcess).toHaveBeenCalledOnce(); + }); + + it("rejects a process with an invalid native process identifier", async () => { + const recorded = recordingBackend(); + const authority = new WindowsNativeAuthority(recorded.backend); + const directory = await authority.admitPath({ + ...control, + path: "C:\\rea", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!directory.ok) throw new Error("fake directory admission failed"); + const root = await authority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees: [], + files: [], + writableDirectories: [], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: [], environment: {} }, + limits: { entries: 1, depth: 1, bytes: 1 }, + }); + if (!root.ok) throw new Error("fake root creation failed"); + const malformed = new FakeProcess(); + Object.defineProperty(malformed, "processId", { value: -1 }); + vi.mocked(recorded.backend.spawnOwnedProcess).mockReset(); + vi.mocked(recorded.backend.spawnOwnedProcess).mockResolvedValueOnce( + ok(malformed), + ); + + await expect( + authority.spawnOwnedProcess({ + ...control, + runtimeProjection: root.value, + limits: { stdoutBytes: 1, stderrBytes: 1 }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { + operation: "spawn_owned_process", + reason: "native_contract_mismatch", + }, + }); + expect(malformed.closeProcess).toHaveBeenCalledOnce(); + }); + + it("defers resource closure across in-flight projection and spawn operations", async () => { + const recorded = recordingBackend(); + const authority = new WindowsNativeAuthority(recorded.backend); + const file = await authority.admitPath({ + ...control, + path: "C:\\rea\\target.exe", + kind: "file", + reparsePolicy: "reject_all", + }); + const directory = await authority.admitPath({ + ...control, + path: "C:\\rea\\jdk", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!file.ok || !directory.ok) throw new Error("fake admission failed"); + + const rootDeferred = + deferred< + Awaited> + >(); + vi.mocked(recorded.backend.createPrivateRoot).mockReturnValueOnce( + rootDeferred.promise, + ); + const trees = [ + { role: "jdk" as const, source: directory.value, destination: "jdk" }, + ]; + const files = [ + { + role: "target" as const, + source: file.value, + destination: "in\\target.exe", + }, + ]; + const writableDirectories = ["home"]; + const launchArguments = ["-version"]; + const launchEnvironment: Record = { HOME: "home" }; + const pendingRoot = authority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees, + files, + writableDirectories, + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { + arguments: launchArguments, + environment: launchEnvironment, + }, + limits: { entries: 10, depth: 3, bytes: 1_000 }, + }); + + trees[0]!.destination = "mutated"; + files[0]!.destination = "mutated"; + writableDirectories.push("mutated"); + launchArguments.push("mutated"); + launchEnvironment.HOME = "mutated"; + const directoryClose = directory.value.close(); + const fileClose = file.value.close(); + expect(recorded.directoryResource.close).not.toHaveBeenCalled(); + expect(recorded.fileResource.close).not.toHaveBeenCalled(); + expect(recorded.backend.createPrivateRoot).toHaveBeenCalledWith( + expect.objectContaining({ + trees: [expect.objectContaining({ destination: "jdk" })], + files: [expect.objectContaining({ destination: "in\\target.exe" })], + writableDirectories: ["home"], + launch: { arguments: ["-version"], environment: { HOME: "home" } }, + }), + ); + + rootDeferred.resolve( + ok({ + resource: recorded.rootResource, + observation: privateRootObservation("C:\\runtime\\rea-1"), + }), + ); + const root = await pendingRoot; + expect(root.ok).toBe(true); + await Promise.all([directoryClose, fileClose]); + expect(recorded.directoryResource.close).toHaveBeenCalledOnce(); + expect(recorded.fileResource.close).toHaveBeenCalledOnce(); + if (!root.ok) throw new Error("fake root creation failed"); + + const spawnDeferred = + deferred< + Awaited> + >(); + vi.mocked(recorded.backend.spawnOwnedProcess).mockReturnValueOnce( + spawnDeferred.promise, + ); + const pendingSpawn = authority.spawnOwnedProcess({ + ...control, + runtimeProjection: root.value, + limits: { stdoutBytes: 2, stderrBytes: 2 }, + }); + const rootClose = root.value.close(); + expect(recorded.rootResource.close).not.toHaveBeenCalled(); + spawnDeferred.resolve(ok(new FakeProcess())); + expect((await pendingSpawn).ok).toBe(true); + await rootClose; + expect(recorded.rootResource.close).toHaveBeenCalledOnce(); + }); + + it("rejects duplicate and cross-kind backend resource tokens", async () => { + const shared = new FakeResource(); + const recorded = recordingBackend(); + vi.mocked(recorded.backend.admitFile).mockResolvedValue( + ok({ + resource: shared, + observation: { + kind: "file", + ...pathIdentity("C:\\rea\\a.exe", "4".repeat(32)), + sha256: "a".repeat(64), + }, + }), + ); + vi.mocked(recorded.backend.admitDirectory).mockResolvedValue( + ok({ + resource: shared, + observation: { + kind: "directory", + ...pathIdentity("C:\\rea", "5".repeat(32)), + sha256: null, + }, + }), + ); + const authority = new WindowsNativeAuthority(recorded.backend); + expect( + ( + await authority.admitPath({ + ...control, + path: "C:\\rea\\a.exe", + kind: "file", + reparsePolicy: "reject_all", + }) + ).ok, + ).toBe(true); + await expect( + authority.admitPath({ + ...control, + path: "C:\\rea\\b.exe", + kind: "file", + reparsePolicy: "reject_all", + }), + ).resolves.toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + await expect( + authority.admitPath({ + ...control, + path: "C:\\rea", + kind: "directory", + reparsePolicy: "reject_all", + }), + ).resolves.toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + + const secondAuthority = new WindowsNativeAuthority(recorded.backend); + await expect( + secondAuthority.admitPath({ + ...control, + path: "C:\\rea\\c.exe", + kind: "file", + reparsePolicy: "reject_all", + }), + ).resolves.toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + expect(shared.close).toHaveBeenCalledOnce(); + }); + + it("defers alias poisoning until an existing resource lease is released", async () => { + const recorded = recordingBackend(); + const authority = new WindowsNativeAuthority(recorded.backend); + const directory = await authority.admitPath({ + ...control, + path: "C:\\rea\\jdk", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!directory.ok) throw new Error("fake directory admission failed"); + recorded.directoryResource.close.mockRejectedValueOnce( + new Error("fixture cleanup rejection"), + ); + + const rootDeferred = + deferred< + Awaited> + >(); + vi.mocked(recorded.backend.createPrivateRoot).mockReturnValueOnce( + rootDeferred.promise, + ); + const pendingRoot = authority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees: [], + files: [], + writableDirectories: [], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: [], environment: {} }, + limits: { entries: 1, depth: 1, bytes: 1 }, + }); + vi.mocked(recorded.backend.admitFile).mockResolvedValueOnce( + ok({ + resource: recorded.directoryResource, + observation: { + kind: "file", + ...pathIdentity("C:\\rea\\alias.exe", "6".repeat(32)), + sha256: "a".repeat(64), + }, + }), + ); + const pendingAlias = authority.admitPath({ + ...control, + path: "C:\\rea\\alias.exe", + kind: "file", + reparsePolicy: "reject_all", + }); + await Promise.resolve(); + expect(recorded.directoryResource.close).not.toHaveBeenCalled(); + + rootDeferred.resolve( + err( + new WindowsNativeAuthorityError( + "create_private_root", + "native_failure", + "fixture failure", + ), + ), + ); + await pendingRoot; + await expect(pendingAlias).resolves.toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + expect(recorded.directoryResource.close).toHaveBeenCalledOnce(); + await expect(directory.value.close()).resolves.toMatchObject({ + ok: false, + error: { operation: "close_resource", reason: "native_failure" }, + }); + }); + + it("preserves alias rejection when registered cleanup rejects immediately", async () => { + const recorded = recordingBackend(); + const authority = new WindowsNativeAuthority(recorded.backend); + const file = await authority.admitPath({ + ...control, + path: "C:\\rea\\provider.exe", + kind: "file", + reparsePolicy: "reject_all", + }); + if (!file.ok) throw new Error("fake file admission failed"); + recorded.fileResource.close.mockRejectedValueOnce( + new Error("fixture cleanup rejection"), + ); + await expect( + authority.admitPath({ + ...control, + path: "C:\\rea\\provider.exe", + kind: "file", + reparsePolicy: "reject_all", + }), + ).resolves.toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + await expect(file.value.close()).resolves.toMatchObject({ + ok: false, + error: { operation: "close_resource", reason: "native_failure" }, + }); + }); + + it("cleans up aliased roots and jobs before rejecting them", async () => { + const rootAlias = recordingBackend(); + const rootAuthority = new WindowsNativeAuthority(rootAlias.backend); + const directory = await rootAuthority.admitPath({ + ...control, + path: "C:\\rea", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!directory.ok) throw new Error("fake admission failed"); + vi.mocked(rootAlias.backend.createPrivateRoot).mockImplementationOnce( + async (input) => + ok({ + resource: rootAlias.directoryResource, + observation: privateRootObservation( + `C:\\runtime\\${input.prefix}alias`, + ), + }), + ); + await expect( + rootAuthority.createPrivateRoot({ + ...control, + parent: directory.value, + prefix: "rea-", + trees: [], + files: [], + writableDirectories: [], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: [], environment: {} }, + limits: { entries: 1, depth: 1, bytes: 1 }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + expect(rootAlias.directoryResource.close).toHaveBeenCalledOnce(); + + const processAlias = recordingBackend(); + const processAuthority = new WindowsNativeAuthority(processAlias.backend); + const processDirectory = await processAuthority.admitPath({ + ...control, + path: "C:\\rea", + kind: "directory", + reparsePolicy: "reject_all", + }); + if (!processDirectory.ok) throw new Error("fake admission failed"); + vi.mocked(processAlias.backend.createPrivateRoot).mockImplementationOnce( + async (input) => + ok({ + resource: processAlias.process, + observation: privateRootObservation( + `C:\\runtime\\${input.prefix}process-alias`, + ), + }), + ); + const root = await processAuthority.createPrivateRoot({ + ...control, + parent: processDirectory.value, + prefix: "rea-", + trees: [], + files: [], + writableDirectories: [], + entrypoint: { treeRole: "jdk", relativePath: "bin\\java.exe" }, + launch: { arguments: [], environment: {} }, + limits: { entries: 1, depth: 1, bytes: 1 }, + }); + if (!root.ok) throw new Error("fake root creation failed"); + await expect( + processAuthority.spawnOwnedProcess({ + ...control, + runtimeProjection: root.value, + limits: { stdoutBytes: 1, stderrBytes: 1 }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + expect(processAlias.process.close).toHaveBeenCalledOnce(); + expect(processAlias.process.closeProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/windowsNativeAuthorityLoader.test.ts b/tests/windowsNativeAuthorityLoader.test.ts new file mode 100644 index 000000000..19c250ef6 --- /dev/null +++ b/tests/windowsNativeAuthorityLoader.test.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + createWindowsNativeAuthorityPackageLoader, + WindowsNativeAuthorityPackageLoader, + type WindowsNativeAuthorityLoadHost, + type WindowsNativeAuthorityPackageCommitment, +} from "../src/application/WindowsNativeAuthorityLoader.js"; +import { + unavailableWindowsNativeAuthorityBackend, + type WindowsNativeAuthorityBackend, +} from "../src/application/WindowsNativeAuthority.js"; + +const artifact = Buffer.from("source-owned-windows-native-addon"); +const digest = createHash("sha256").update(artifact).digest("hex"); +const packageManifestObject = { + name: "@rea/windows-x64", + version: "2.0.0", + os: ["win32"], + cpu: ["x64"], + reaNativeAuthority: { + contractVersion: 1, + nodeApiVersion: 9, + artifact: "rea.node", + artifactSha256: digest, + exports: [ + "admitDirectory", + "admitFile", + "createPrivateRoot", + "identity", + "spawnOwnedProcess", + ], + }, +} as const; +const packageManifest = Buffer.from(JSON.stringify(packageManifestObject)); + +const commitment: WindowsNativeAuthorityPackageCommitment = { + packageName: "@rea/windows-x64", + packageVersion: "2.0.0", + contractVersion: 1, + nodeApiVersion: 9, + artifactSha256: digest, +}; + +const backend = (): WindowsNativeAuthorityBackend => ({ + ...unavailableWindowsNativeAuthorityBackend("not exercised"), + identity: { + packageName: commitment.packageName, + packageVersion: commitment.packageVersion, + contractVersion: commitment.contractVersion, + nodeApiVersion: commitment.nodeApiVersion, + artifactSha256: digest, + }, +}); + +class RecordingLoadHost implements WindowsNativeAuthorityLoadHost { + readonly platform: NodeJS.Platform; + readonly architecture: string; + readonly nodeApiVersion: number | null; + readonly events: string[] = []; + module: unknown = backend(); + bytes: Uint8Array = artifact; + manifestBytes: Uint8Array = packageManifest; + cachedValue: unknown | undefined; + resolveFailure: unknown | undefined; + + constructor( + options: { + readonly platform?: NodeJS.Platform; + readonly architecture?: string; + readonly nodeApiVersion?: number | null; + } = {}, + ) { + this.platform = options.platform ?? "win32"; + this.architecture = options.architecture ?? "x64"; + this.nodeApiVersion = + options.nodeApiVersion === undefined ? 10 : options.nodeApiVersion; + } + + resolve(packageName: string): string { + this.events.push(`resolve:${packageName}`); + if (this.resolveFailure !== undefined) throw this.resolveFailure; + return "C:\\rea\\node_modules\\@rea\\windows-x64\\rea.node"; + } + + resolveManifest(packageName: string): string { + this.events.push(`resolve-manifest:${packageName}`); + return "C:\\rea\\node_modules\\@rea\\windows-x64\\package.json"; + } + + async canonicalize(path: string): Promise { + this.events.push("canonicalize"); + return path.toLowerCase(); + } + + async read(path: string): Promise { + if (path.endsWith("package.json")) { + this.events.push("read-manifest"); + return this.manifestBytes; + } + this.events.push("read-addon"); + return this.bytes; + } + + cached(_path: string): unknown | undefined { + this.events.push("cached"); + return this.cachedValue; + } + + load(_path: string): unknown { + this.events.push("load"); + return this.module; + } +} + +describe("Windows native authority package loader", () => { + it("owns one production loader for the process lifetime", () => { + expect(createWindowsNativeAuthorityPackageLoader()).toBe( + createWindowsNativeAuthorityPackageLoader(), + ); + }); + + it("loads and pins one exact release tuple idempotently", async () => { + const host = new RecordingLoadHost(); + const loader = new WindowsNativeAuthorityPackageLoader(host, commitment); + + const [first, concurrent] = await Promise.all([ + loader.load(), + loader.load(), + ]); + const second = await loader.load(); + + expect(first).toMatchObject({ ok: true }); + expect(concurrent).toEqual(first); + expect(second).toEqual(first); + expect(host.events).toEqual([ + "resolve-manifest:@rea/windows-x64", + "canonicalize", + "read-manifest", + "resolve:@rea/windows-x64", + "canonicalize", + "canonicalize", + "cached", + "read-addon", + "load", + ]); + }); + + it.each([ + ["wrong platform", { platform: "linux" as const }, commitment], + ["wrong architecture", { architecture: "arm64" }, commitment], + ["old Node-API", { nodeApiVersion: 8 }, commitment], + ["missing Node-API", { nodeApiVersion: null }, commitment], + ["uncommitted artifact", {}, { ...commitment, artifactSha256: null }], + ])( + "fails before package resolution for %s", + async (_name, options, expected) => { + const host = new RecordingLoadHost(options); + const result = await new WindowsNativeAuthorityPackageLoader( + host, + expected, + ).load(); + + expect(result).toMatchObject({ + ok: false, + error: { reason: "unavailable" }, + }); + expect(host.events).toEqual([]); + }, + ); + + it("fails closed when the optional package is absent", async () => { + const host = new RecordingLoadHost(); + host.resolveFailure = new Error("module details must not escape"); + + const result = await new WindowsNativeAuthorityPackageLoader( + host, + commitment, + ).load(); + + expect(result).toMatchObject({ + ok: false, + error: { + reason: "unavailable", + message: + "Windows native authority package is not installed or resolvable", + }, + }); + expect(host.events).toEqual([ + "resolve-manifest:@rea/windows-x64", + "canonicalize", + "read-manifest", + "resolve:@rea/windows-x64", + ]); + }); + + it("rejects platform manifest drift before addon resolution", async () => { + const host = new RecordingLoadHost(); + host.manifestBytes = Buffer.from( + JSON.stringify({ + ...packageManifestObject, + version: "1.0.0", + }), + ); + + const result = await new WindowsNativeAuthorityPackageLoader( + host, + commitment, + ).load(); + + expect(result).toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + expect(host.events).not.toContain("resolve:@rea/windows-x64"); + expect(host.events).not.toContain("load"); + }); + + it("rejects cache contamination and digest drift before evaluation", async () => { + const cached = new RecordingLoadHost(); + cached.cachedValue = backend(); + const cachedResult = await new WindowsNativeAuthorityPackageLoader( + cached, + commitment, + ).load(); + expect(cachedResult).toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + expect(cached.events).not.toContain("read-addon"); + expect(cached.events).not.toContain("load"); + + const drifted = new RecordingLoadHost(); + drifted.bytes = Buffer.from("different-addon"); + const driftedResult = await new WindowsNativeAuthorityPackageLoader( + drifted, + commitment, + ).load(); + expect(driftedResult).toMatchObject({ + ok: false, + error: { reason: "identity_drift" }, + }); + expect(drifted.events).not.toContain("load"); + }); + + it.each([ + ["non-object", null], + ["extra export", { ...backend(), unexpected: true }], + ["missing operation", { ...backend(), admitFile: undefined }], + [ + "wrong package version", + { + ...backend(), + identity: { ...backend().identity, packageVersion: "1.0.0" }, + }, + ], + ])("rejects a %s native contract", async (_name, module) => { + const host = new RecordingLoadHost(); + host.module = module; + + const result = await new WindowsNativeAuthorityPackageLoader( + host, + commitment, + ).load(); + + expect(result).toMatchObject({ + ok: false, + error: { reason: "native_contract_mismatch" }, + }); + }); +});