diff --git a/README.md b/README.md index ec1a1eac8..370e0974e 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ rea uninstall rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state ``` -Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks. +Uninstall preserves Hopper, Node.js, Evidence files, captures, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks. ### CLI or agent? @@ -266,35 +266,38 @@ Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots | Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent | | Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` | | Validate, canonicalize, or compare Evidence bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` | -| Map a local JavaScript/Electron application without executing it | `rea analyze PATH --approved` or `rea analyze-javascript-application` | +| Map a local JavaScript/Electron application without executing it | `rea analyze PATH` or `rea analyze-javascript-application` | | Reuse immutable analysis results without relaunching a provider | Pass `--snapshot /approved/path/analysis.json` to a deep-analysis command | | Import source as historical reference | `rea import-reference-source` | | Capture or compare controlled process behavior | `rea capture-process` or `rea compare-process-captures` | -Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots: - ```bash -export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]' rea evidence-import /absolute/path/to/evidence/bundle.json rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json -rea analyze /absolute/path/to/releases/app.asar --approved --json -rea analyze-javascript-application /absolute/path/to/releases/app.asar --approved --json +``` + +JavaScript application analysis reads the selected directory or ASAR directly; +the command uses the supplied path directly: + +```bash +rea analyze /absolute/path/to/releases/app.asar --json +rea analyze-javascript-application /absolute/path/to/releases/app.asar --json ``` For a directory or `.asar`, generic `rea analyze` automatically selects the static JavaScript application provider when neither `--provider` nor -`--snapshot` is supplied. The dedicated command remains available for explicit -format and traversal controls. Both routes read the selected local input path. +`--snapshot` is supplied. Both routes return the analysis and its Evidence +context inline. -Historical source import requires a separate allowlist and never treats source as current behavioral authority: +Historical source import takes the directory directly and never treats source +as current behavioral authority: ```bash -export REA_REFERENCE_ROOTS_JSON='["/absolute/path/to/source"]' rea import-reference-source /absolute/path/to/source ``` -Exports never replace an existing file unless `--overwrite` is explicit. Imports are size/depth bounded, validate every Evidence ID and manifest, and never execute bundle content. +Imports read the path supplied to the command and validate every Evidence ID and manifest. Exports never replace an existing file unless `--overwrite` is explicit. Provider-neutral analysis snapshots persist successful, immutable REA calls and their Evidence records. They are exact caches rather than Hopper databases: @@ -302,16 +305,14 @@ REA reuses a v2 entry only when the binary digest, kind, format, architecture, operation parameters, concrete provider build, and canonical analysis-profile digest match. Hopper loader defaults and configured overrides are normalized by the Hopper adapter and committed to that profile, so overrides occupy a distinct -safe cache partition instead of disabling snapshots. Snapshot v1 cannot prove -those semantics and is rejected with recapture guidance. Cursor-dependent and +safe cache partition instead of disabling snapshots. Cursor-dependent and mutating calls are never cached. Snapshot files can contain proprietary analysis -results and local paths, so REA keeps them local, writes them with owner-only -permissions, and requires a separate approved root: +results and local paths, so REA keeps them local and writes them with owner-only +permissions. The caller supplies the snapshot path directly: ```bash -export REA_ANALYSIS_SNAPSHOT_ROOTS_JSON='["/absolute/path/to/analysis"]' rea analyze /absolute/path/to/app --snapshot /absolute/path/to/analysis/app.json -# The same exact query can now be answered from the snapshot. +# The same exact query can be answered from that snapshot. rea analyze /absolute/path/to/app --snapshot /absolute/path/to/analysis/app.json ``` @@ -380,8 +381,8 @@ REA is already useful for native application, browser, and Electron investigatio - Attach to a user-owned Chrome-family browser over a configured loopback CDP endpoint; capture exact-origin web structure, safe metadata, approved value-free payload shapes, bundle/source-map evidence, WebMCP declarations, user-action timelines, capture diffs, and explicitly approved screenshots without navigation or JavaScript evaluation. - Inspect Electron `file://` renderer pages through a separate canonical-root permission boundary without invoking Electron APIs; script contents remain separately approved and byte bounded. - Attach to one exact approved Node or Electron V8 Inspector target and retain bounded `scriptParsed` plus execution-context lifecycle metadata without evaluation, breakpoints, resume, source reads, or instrumentation. require/import edges, EventEmitter activity, Electron IPC, PID identity, and role identity remain unknown. See [passive Node and Electron runtime observation](docs/javascript-runtime-observation.md). -- Validate and canonically serialize a provider-neutral [JavaScript Application Graph v1](docs/javascript-application-graph.md) spanning packages, ASAR entries, Electron roles, JavaScript/source-map entities, browser/runtime instances, IPC, endpoints, storage, and native add-ons. This shipped domain contract performs no extraction or I/O by itself. -- Reconstruct bounded static package, entrypoint, Webpack/Rspack module, import, worker, endpoint, storage, source-map, BrowserWindow, preload, contextBridge, IPC, utility-process, and native-add-on structure from an approved local directory or ASAR through `analyze_javascript_application` or `rea analyze-javascript-application`. The AST-only [application service](docs/javascript-artifact-reconstruction.md) never executes bootstrap code, pairs only unique exact literal IPC channels, and reports dynamic or ambiguous channels as unresolved. +- Validate and canonically serialize a provider-neutral [JavaScript Application Graph](docs/javascript-application-graph.md) spanning packages, ASAR entries, Electron roles, JavaScript/source-map entities, browser/runtime instances, IPC, endpoints, storage, and native add-ons. This shipped domain contract performs no extraction or I/O by itself. +- Reconstruct static package, entrypoint, Webpack/Rspack module, import, worker, endpoint, storage, source-map, BrowserWindow, preload, contextBridge, IPC, utility-process, and native-add-on structure from a selected local directory or ASAR through `analyze_javascript_application` or `rea analyze-javascript-application`. Results and Evidence context are returned inline. The AST-only [application service](docs/javascript-artifact-reconstruction.md) never executes bootstrap code, pairs only unique exact literal IPC channels, and reports dynamic or ambiguous channels as unresolved. - Reconcile that static graph with existing passive web or Electron Evidence through `reconcile_javascript_runtime` or `rea reconcile-javascript-runtime`. Exact captured bytes outrank caller-declared file/URL mappings; target, frame, script, worker, cache, and asset ambiguity stays explicit, source-map authority stays separate, and a module resident in an observed bundle is never reported as executed. See [JavaScript static/runtime reconciliation](docs/javascript-runtime-reconciliation.md). - Trace a literal route, string, API, IPC channel, module, or native export through authenticated application Evidence with explicit traversal bounds, then hand exact native artifact digests and requested exports to retained Ghidra or Hopper Evidence without automatic provider switching. Compare application versions using unique-only digest, source-map, structural, and semantic tiers; map a committed historical source inventory to bundle nodes with explicit digest and path scores; compare one exact JavaScript export's static return shapes through unique literal discriminants and bounded JSON Pointer changes. Duplicate, dynamic, incomplete, ambiguous, and truncated facts stay unknown. See [cross-layer JavaScript application workflows](docs/javascript-application-workflows.md). - Classify PE/CLI managed artifacts with `inspect_managed_artifact` / `rea inspect-managed-artifact`, inspect bounded metadata members, signatures, raw CIL hashes, limited decoded-instruction-tuple v1 hashes, separately reported exception regions, call edges, and field-access anchors with `inspect_managed_members` / `rea inspect-managed-members`, inventory declared ModuleRef/ImplMap/PInvoke and non-IL method boundary indicators with `inspect_managed_native_boundaries` / `rea inspect-managed-native-boundaries`, then compare two authenticated member observations with `compare_managed_members` / `rea compare-managed-members`. `verify_managed_native_boundaries` / `rea verify-managed-native-boundaries` checks managed P/Invoke declarations against authenticated native export or function Evidence while keeping verified, inferred, contradicted, and unresolved states distinct. The comparison treats build-local tokens as build-local and uses unique decoded-CIL/signature and structural method-shape tiers, never names alone; the v1 tuple hash does not itself resolve tokens or fully commit control flow. `project_managed_application_graph` / `rea project-managed-application-graph` projects authenticated managed artifact/member/native-boundary Evidence into the existing application graph for cross-layer feature tracing. `import_managed_reconstruction` / `rea import-managed-reconstruction` admits user-supplied decompiler C#/IL/pseudocode as analyst inference only after exact artifact SHA-256, MVID, signature, and the shipped v1 decoded-IL commitment match. Separately, `plan_managed_runtime_correlation` / `rea plan-managed-runtime-correlation` can admit a default-disabled, permission-gated runtime-correlation plan locked to the same build evidence. These paths never load the assembly, resolve CLR dependencies, execute target code, run a decompiler, or translate managed tokens into native addresses; full normalized-CIL v2 semantics, native-body bridge mapping, and an actual runtime executor remain future managed-code contracts. diff --git a/docs/adr/0002-controlled-replay-authority-and-sandbox.md b/docs/adr/0002-controlled-replay-authority-and-sandbox.md index dced78982..95f772c93 100644 --- a/docs/adr/0002-controlled-replay-authority-and-sandbox.md +++ b/docs/adr/0002-controlled-replay-authority-and-sandbox.md @@ -168,9 +168,10 @@ requires an active permission grant and literal per-call approval bound to the exact replay plan. Project, session, and administrator grants may satisfy the permission-policy layer, but none replace that per-run approval. -Reproducer export is a separate host write after the sandbox has stopped. It -uses the existing bounded `evidence_write` authority and a separate export -approval; `javascript_replay` alone never writes a host file. +Reproducer export is a separate host write after the sandbox has stopped. This +ADR's root-scoped `evidence_write` authority was removed; current exports use +the path supplied by the caller, while replay still waits for complete sandbox +cleanup before writing. ### 2. Admit extracted modules only diff --git a/docs/controlled-javascript-replay.md b/docs/controlled-javascript-replay.md index 66abd2e79..1263c6125 100644 --- a/docs/controlled-javascript-replay.md +++ b/docs/controlled-javascript-replay.md @@ -79,8 +79,8 @@ Each left/right run is retained as its own `observed` source Evidence. A differential envelope is `derived` and links those source Evidence IDs, so the comparison never erases the underlying observations. -Optional `reproducer_export` is committed by the plan and requires separate -`evidence_write` authority. +Optional `reproducer_export` is committed by the plan and writes to its +caller-supplied path after the sandbox has stopped. The owner-only manifest is written only after complete sandbox cleanup. Source bytes are excluded unless `include_sources: true` was explicitly approved. An export failure is retained in the result and does not erase a completed diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index ae109803f..8ebdeb799 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -178,8 +178,8 @@ promote static inference into passive runtime observation or prove that the original application, renderer, preload, main process, or remote service behaved identically. -Module and explicit case lists have no fixed item-count ceiling. Planning checks -the complete request against the committed worker-protocol byte limit before -probing executables or reading modules; aggregate module-source and case-input -bytes have their own limits. Preset-generated cases remain capped at 64 because -REA expands that scalar request into case records before sandbox startup. +Module, explicit case, and preset-generated case lists have no fixed +item-count ceiling. Replay plans commit the caller's resource budgets, and the +worker protocol checks the complete request against that declared byte budget +before probing executables or reading modules. Aggregate module-source and +case-input bytes are likewise checked against the caller's declared budgets. diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index d86437387..0f51a093b 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -1,11 +1,11 @@ # JavaScript artifact reconstruction REA can project an operator-supplied Electron/JavaScript application directory -or ASAR into [JavaScript Application Graph v1](javascript-application-graph.md) +or ASAR into [JavaScript Application Graph](javascript-application-graph.md) without executing application code. The target-free `analyze_javascript_application` MCP tool, dedicated `rea analyze-javascript-application` CLI command, and generic -`rea analyze PATH --approved` directory/ASAR route expose the same application +`rea analyze PATH` directory/ASAR route expose the same application service and return an Evidence envelope containing the graph and an Electron boundary summary. @@ -26,10 +26,10 @@ rea analyze-javascript-application /absolute/path/to/apps/app.asar \ ``` Generic `rea analyze` selects this static provider for directories and `.asar` -paths when neither `--provider` nor `--snapshot` is supplied. Use the dedicated -command when setting explicit format or reconstruction limits. Native targets, -single JavaScript files, `.app` bundles, and explicit deep-provider or snapshot -requests retain the native deep-analysis route. +paths when neither `--provider` nor `--snapshot` is supplied. The dedicated +command and the generic route return their complete results inline. Native +targets, single JavaScript files, `.app` bundles, and explicit deep-provider or +snapshot requests retain the native deep-analysis route. Configure an MCP client with the ordinary REA setup command: @@ -48,7 +48,7 @@ The equivalent MCP input is: `input_path` must be absolute. `format` accepts `auto`, `asar`, or `directory`. The result retains the canonical local path, root artifact digest, artifact -manifest and graph commitments, JavaScript Application Graph v1, static +manifest and graph commitments, JavaScript Application Graph, static Electron summary, reconstruction statistics, and explicit limitations. It does not require a live Hopper, Ghidra, browser, or Electron process. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index dc0cdda2f..b8be76f14 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -83,12 +83,11 @@ Existing environment settings map to administrator ceilings. They remain the maximum authority: - process roots, executables, environment names, and external networking; -- Evidence read/write roots; -- investigation input and workspace roots; -- snapshot read/write roots; -- reference-source roots; - native mount enablement. +Evidence bundles and analysis snapshots use the path supplied to the CLI or MCP +tool; they do not require configured roots or separate read/write grants. + Artifact extraction takes no path or occurrence selectors. It materializes all regular files into a fresh REA-chosen temporary directory. The write still passes through the active permission evaluator; the tool caller does not choose @@ -122,7 +121,7 @@ administrator ceiling. A request outside that ceiling reports `elicitation_supported: false` and `restart_required: true`; interactive consent cannot silently widen administrator policy. -`analyze_javascript_application` reads the selected local directory or ASAR directly. It has no per-call approval flag or configured investigation root. +`analyze_javascript_application` reads the supplied local directory or ASAR path directly and returns its result and Evidence inline; no approval flag or root configuration is needed. ## Integrity record-and-continue diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 4b564b410..306f9da47 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -501,9 +501,9 @@ "mcp_prompts": 6 }, "digests": { - "tools_sha256": "623e9fd65a117626fe8e3cc4715a8ab8ba694120202ea6413103552b5f5bfefb", + "tools_sha256": "de5819e4e0d2c141e71493fb32efedebf3b600d0bac70a71073cecbe21dd34ed", "prompts_sha256": "7f7f15d84efdce3487412ea64fdf4b83d3d6cf01b42c7fadc3e046799a2b0b15", - "combined_sha256": "d37c2bd4930e6065896ba8085f03681c2b376d9d236a6798d55b466ba42749d9", + "combined_sha256": "ac4da7d924b2f65ff14108d03f3838cebbbf8c92c9ae3034165375e0b12678fd", "providers_sha256": "7247eece8fad09c8023af8d5c682d223d7ed7ebd68eac79e9154962a7e03b66b" } } diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index b7df8b56e..89ea16f33 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -32,10 +32,10 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement 24.93,src/server/registerFunctionComparisonTool.ts:probe rationale,Function comparison is incomplete,Capture complete dossiers with equal providers and analysis limits.,Exposes provider architecture and gives ambiguous equality guidance,"Capture complete dossiers for both functions under the same target context and analysis limits.",implemented,verified by focused function-comparison MCP test 24.95,incur:CLI argument validation,Caller omits or mistypes a command argument,VALIDATION_ERROR with raw Zod Details and fieldErrors including code/path/expected/received,Exposes validator representation and buries the useful correction,"REA could not read the command arguments. Check the named argument and its expected type, then try again.",implemented,verified by sanitizer unit tests and real dispatcher missing-argument test 25,src/application/ReferenceSourceImport.ts:cancelled,Reference-source import is cancelled,Reference source import cancelled,No recovery timing guidance,"Reference source import was cancelled. Start it again when ready.",implemented,focused assertions passed -26,src/application/ReferenceSourceImportPolicy.ts:invalid-limits,Configured import limits are not positive integers,Import limits must be positive integers,Does not tell user where recovery lies,"Reference source limits are invalid. Use positive integer limits, then try again.",implemented,focused assertions passed +26,src/application/ReferenceSourceImportPolicy.ts:invalid-limits,Configured import limits are not positive integers,Import limits must be positive integers,Fixed importer ceilings prevented full source imports,Removed fixed byte, entry, depth, and path limits.,removed,verified by greater-than-10000 entry and greater-than-16-MiB regressions 27,src/application/ReferenceSourceImportPolicy.ts:invalid-root,Source path cannot resolve or is not a directory,Reference source root could not be resolved / Reference source root is not a directory,Multiple low-level copies and no complete path check,"Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.",implemented,focused assertions passed -28,src/application/ReferenceSourceImportPolicy.ts:policy,No approved root exists or requested root is outside policy,No approved reference source roots / Reference source root is outside approved roots,Does not identify safe configuration recovery,"Reference source directory is not approved. Add its directory to `REA_REFERENCE_ROOTS_JSON`, restart REA, then try again.",implemented,focused assertions passed -29,src/application/ReferenceSourceImport.ts:io,Reader fails during bounded traversal,Raw reader error message,May expose filesystem details and inconsistent recovery,"Reference source files could not be read. Check directory permissions and try again.",implemented,focused assertions passed +28,src/application/ReferenceSourceImportPolicy.ts:policy,No approved root exists or requested root is outside policy,No approved reference source roots / Reference source root is outside approved roots,Approval friction duplicates direct caller path selection,Removed the configured root allowlist and permission gate; caller-selected directories are validated and traversed directly.,removed,verified by source search and focused import tests +29,src/application/ReferenceSourceImport.ts:io,Reader fails during traversal,Raw reader error message,May expose filesystem details and inconsistent recovery,"Reference source files could not be read. Check directory permissions and try again.",implemented,focused assertions passed 30,src/application/ReferenceSourceImport.ts:parse,Historical graph validation fails,Raw thrown graph validation message,Can expose internal graph invariants,"Reference source could not be indexed. Check that the source tree is readable, then try again.",implemented,focused assertions passed 31,src/application/ReferenceSourceImportTypes.ts:limit,Import reports terminal limit failure,No reachable constructor found,Declared state may be unreachable; false verification risk,Remove unreachable top-level limit variant; per-entry limit guidance remains caller-visible.,removed,"verified by typecheck, exhaustive projection test, and source search" 32,src/application/Setup.ts:configureDetectedClients(backup),Agent configuration backup fails,"{clientName} configuration backup verification failed; no successful configuration was reported.",Exposes machine client key and obscures no-write guarantee,"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",implemented,focused assertions passed diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index dcf15388e..7870b96aa 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -1,5 +1,5 @@ { - "manifest_id": "ecm_0107a34d5c39457fbb22d1b86983e97ef618787d676300664cb2bb8c9efa738b", + "manifest_id": "ecm_627d211d59b75217aa6efd6e83458bc616a50657ab1f1a63415b3b343ca6d637", "verifier": { "id": "rea-managed-conformance", "version": "1" @@ -13,7 +13,7 @@ "skill_digests": [ { "skill_id": "reverse-engineer-anything", - "sha256": "dad216a904af18f4eb364995c5d70ff691aa2ff2e841a169f583f50bec03530d" + "sha256": "31f5866710faafbf13d13f7f21b652357178b6667dd14ec958e5ecfd24db9a60" } ], "claims": [ diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs index 22cf1bea9..824c93ddf 100644 --- a/scripts/verify-package-environment.mjs +++ b/scripts/verify-package-environment.mjs @@ -74,8 +74,6 @@ export async function verifyPackageEnvironment({ REA_ANALYSIS_PROVIDER: "auto", ...packageHopperEnvironment(root), REA_NPX_LOG: npxLog, - REA_EVIDENCE_ROOTS_JSON: JSON.stringify([evidenceRoot]), - REA_REFERENCE_ROOTS_JSON: JSON.stringify([referenceRoot]), REA_REFERENCE_SECRET_PATTERNS_JSON: JSON.stringify([".env"]), }; return { diff --git a/src/application/AnalysisSnapshotFiles.ts b/src/application/AnalysisSnapshotFiles.ts index a9700663d..02d3c0130 100644 --- a/src/application/AnalysisSnapshotFiles.ts +++ b/src/application/AnalysisSnapshotFiles.ts @@ -3,19 +3,17 @@ import { serializeAnalysisSnapshot, type AnalysisSnapshot, } from "../domain/analysisSnapshot.js"; -import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; -import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js"; +import { readJsonFile, writeTextFile } from "./JsonFiles.js"; type SnapshotFailure = EvidenceFileError | EvidenceIntegrityError; -/** Read and validate a bounded analysis snapshot inside an approved root. */ +/** Read and validate an analysis snapshot from the caller's path. */ export const readAnalysisSnapshot = async ( path: string, - policy: EvidenceFilePolicy, ): Promise> => { - const loaded = await readBoundedJson(path, policy); + const loaded = await readJsonFile(path); if (!loaded.ok) return loaded; try { return ok(parseAnalysisSnapshot(loaded.value)); @@ -26,12 +24,11 @@ export const readAnalysisSnapshot = async ( } }; -/** Atomically write a deterministic analysis snapshot inside an approved root. */ +/** Atomically write a deterministic analysis snapshot to the caller's path. */ export const writeAnalysisSnapshot = async ( snapshot: AnalysisSnapshot, path: string, overwrite: boolean, - policy: EvidenceFilePolicy, ): Promise< Result<{ readonly path: string; readonly bytes: number }, SnapshotFailure> > => { @@ -43,7 +40,7 @@ export const writeAnalysisSnapshot = async ( new EvidenceIntegrityError(snapshotValidationMessage(cause), { cause }), ); } - return writeBoundedText(encoded, path, overwrite, policy); + return writeTextFile(encoded, path, overwrite); }; const snapshotValidationMessage = (cause: unknown): string => diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 347823769..86538819d 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -31,10 +31,6 @@ import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js"; import type { PermissionAuthority } from "./PermissionAuthority.js"; -import { - authorizeFileReadWithDeferredWrite, - type DeferredFileWriteAuthorization, -} from "./DeferredFileAuthorization.js"; import { REA_WORKFLOW_PROVIDER, workflowAnalysisProfile, @@ -155,31 +151,6 @@ const permissionAuthorityFor = ( ? loadConfiguredPermissionAuthority(config) : Promise.resolve(ok(supplied)); -const authorizeSnapshotAccess = async ( - authority: PermissionAuthority, - tool: - | NativeToolName - | ArtifactAnalysisOperation - | ManagedToolName - | DirectAnalysisTool, - snapshotPath: string | undefined, -): Promise< - Result -> => - snapshotPath === undefined - ? ok(undefined) - : authorizeFileReadWithDeferredWrite(authority, { - path: snapshotPath, - readCapability: "snapshot_read", - writeCapability: "snapshot_write", - operation: tool, - }); - -const authorizeDeferredWrite = ( - authorization: DeferredFileWriteAuthorization | undefined, -): Promise> => - authorization?.authorizeWrite() ?? Promise.resolve(ok(null)); - const authorizeAnalysisRun = async (input: { readonly config: AppConfig; readonly suppliedAuthority: PermissionAuthority | undefined; @@ -189,10 +160,7 @@ const authorizeAnalysisRun = async (input: { | ManagedToolName | DirectAnalysisTool; readonly arguments: Readonly>; - readonly snapshotPath: string | undefined; -}): Promise< - Result -> => { +}): Promise> => { const authority = await permissionAuthorityFor( input.config, input.suppliedAuthority, @@ -204,11 +172,7 @@ const authorizeAnalysisRun = async (input: { input.arguments, ); if (!operation.ok) return operation; - return authorizeSnapshotAccess( - authority.value, - input.tool, - input.snapshotPath, - ); + return ok(null); }; const runAnalysis = async ( @@ -235,7 +199,6 @@ const runAnalysis = async ( suppliedAuthority: options.permissionAuthority, tool, arguments: arguments_, - snapshotPath, }); if (!authorization.ok) return cliError(authorization.error); const session = createBinarySession(config.value, logger); @@ -243,7 +206,6 @@ const runAnalysis = async ( const prepared = await prepareSnapshot({ path, snapshotPath, - policy: config.value.analysisSnapshotFilePolicy, }); if (!prepared.ok) return cliError(prepared.error); const { snapshot } = prepared.value; @@ -272,8 +234,6 @@ const runAnalysis = async ( }); if (cached !== undefined) return cached; } - const writable = await authorizeDeferredWrite(authorization.value); - if (!writable.ok) return cliError(writable.error); const { output, evidence } = await executeAnalysisTool({ session, openedTarget: opened.value, @@ -290,7 +250,6 @@ const runAnalysis = async ( snapshot.value, snapshotPath, true, - config.value.analysisSnapshotFilePolicy, ); if (!written.ok) return cliError(written.error); } @@ -386,14 +345,13 @@ const withProcessCancellation = async ( const prepareSnapshot = async (options: { readonly path: string; readonly snapshotPath: string | undefined; - readonly policy: Parameters[1]; }): Promise< Result<{ readonly snapshot?: AnalysisSnapshot }, AnalysisError> > => { - const { path, snapshotPath, policy } = options; + const { path, snapshotPath } = options; if (snapshotPath === undefined || !(await fileExists(snapshotPath))) return ok({}); - const loaded = await readAnalysisSnapshot(snapshotPath, policy); + const loaded = await readAnalysisSnapshot(snapshotPath); if (!loaded.ok) return loaded; const target = await parseBinaryTarget(path); if (!target.ok) return target; diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 4c1939222..d24c00dc6 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -2,10 +2,7 @@ import { EvidenceIntegrityError, type AnalysisError, } from "../domain/errors.js"; -import type { - EvidenceFilePolicy, - EvidenceBundle, -} from "../domain/evidenceBundle.js"; +import type { EvidenceBundle } from "../domain/evidenceBundle.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -19,9 +16,8 @@ import { /** Validate and merge one bundle using the session Evidence ledger. */ export const importEvidenceBundleCommand = async ( path: string, - policy: EvidenceFilePolicy, ): Promise> => { - const loaded = await readEvidenceBundle(path, policy); + const loaded = await readEvidenceBundle(path); if (!loaded.ok) return loaded; const ledger = createLedger(); const imported = ledger.import(loaded.value); @@ -39,13 +35,12 @@ export const exportEvidenceBundleCommand = async ( sourcePath: string, outputPath: string, overwrite: boolean, - policy: EvidenceFilePolicy, ): Promise> => { - const loaded = await readEvidenceBundle(sourcePath, policy); + const loaded = await readEvidenceBundle(sourcePath); if (!loaded.ok) return loaded; return projectWrite( loaded.value, - await writeEvidenceBundle(loaded.value, outputPath, overwrite, policy), + await writeEvidenceBundle(loaded.value, outputPath, overwrite), ); }; @@ -53,11 +48,10 @@ export const exportEvidenceBundleCommand = async ( export const compareEvidenceBundlesCommand = async (input: { readonly leftPath: string; readonly rightPath: string; - readonly policy: EvidenceFilePolicy; }): Promise> => { const [left, right] = await Promise.all([ - readEvidenceBundle(input.leftPath, input.policy), - readEvidenceBundle(input.rightPath, input.policy), + readEvidenceBundle(input.leftPath), + readEvidenceBundle(input.rightPath), ]); if (!left.ok) return left; if (!right.ok) return right; diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index e01f605a7..35a478dc6 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -2,24 +2,20 @@ import { parseEvidenceBundle, serializeEvidenceBundle, type EvidenceBundle, - type EvidenceFilePolicy, } from "../domain/evidenceBundle.js"; import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; import { parseProcessCapture } from "../domain/processCapture.js"; -import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js"; +import { readJsonFile, writeTextFile } from "./JsonFiles.js"; type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError; type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError; -/** Read and validate a bounded evidence bundle inside an approved root. */ +/** Read and validate an evidence bundle at the caller-supplied path. */ export const readEvidenceBundle = async ( path: string, - policy: EvidenceFilePolicy, ): Promise> => { - if (policy.roots.length === 0) - return err(new EvidenceFileError("read", "disabled")); - const loaded = await readBoundedJson(path, policy); + const loaded = await readJsonFile(path); if (!loaded.ok) return loaded; try { const bundle = parseEvidenceBundle(loaded.value); @@ -37,20 +33,17 @@ export const readEvidenceBundle = async ( } }; -/** Atomically write deterministic evidence JSON inside an approved root. */ +/** Atomically write deterministic evidence JSON at the caller-supplied path. */ export const writeEvidenceBundle = async ( bundle: EvidenceBundle, path: string, overwrite: boolean, - policy: EvidenceFilePolicy, ): Promise< Result< { readonly path: string; readonly bytes: number }, EvidenceWriteFailure > > => { - if (policy.roots.length === 0) - return err(new EvidenceFileError("write", "disabled")); let encoded: string; try { encoded = serializeEvidenceBundle(bundle); @@ -61,5 +54,5 @@ export const writeEvidenceBundle = async ( }), ); } - return writeBoundedText(encoded, path, overwrite, policy); + return writeTextFile(encoded, path, overwrite); }; diff --git a/src/application/BoundedJsonFiles.ts b/src/application/JsonFiles.ts similarity index 57% rename from src/application/BoundedJsonFiles.ts rename to src/application/JsonFiles.ts index a264fdb60..1b6af75eb 100644 --- a/src/application/BoundedJsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -1,74 +1,45 @@ -import { link, lstat, mkdtemp, open, realpath, rm } from "node:fs/promises"; +import { link, lstat, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import writeFileAtomic from "write-file-atomic"; -import { isPathWithinRoot } from "../domain/localPath.js"; -import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; import { EvidenceFileError } from "../domain/errors.js"; -import { isJsonWithinLimits } from "../domain/jsonLimits.js"; import { err, ok, type Result } from "../domain/result.js"; -import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; -import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js"; -/** Read bounded JSON data from a regular file beneath an approved root. */ -export const readBoundedJson = async ( +/** Read JSON data from a regular file at the caller-supplied path. */ +export const readJsonFile = async ( path: string, - policy: EvidenceFilePolicy, ): Promise> => { - if (policy.roots.length === 0) - return err(new EvidenceFileError("read", "disabled")); try { const canonicalPath = await realpath(resolve(path)); - if (!(await isApproved(canonicalPath, policy.roots))) - return err(new EvidenceFileError("read", "outside-root")); const stats = await lstat(canonicalPath); if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file")); - if (stats.size > policy.maxBytes) - return err(new EvidenceFileError("read", "too-large")); - const handle = await open(canonicalPath, "r"); - let encoded: Buffer | undefined; - try { - encoded = await readBoundedFileBytes(handle, policy.maxBytes); - } finally { - await handle.close(); - } - if (encoded === undefined) - return err(new EvidenceFileError("read", "too-large")); + const encoded = await readFile(canonicalPath); let decoded: unknown; try { decoded = JSON.parse(encoded.toString("utf8")); } catch (cause: unknown) { return err(new EvidenceFileError("read", "invalid-json", { cause })); } - return isJsonWithinLimits(decoded, policy) - ? ok(decoded) - : err(new EvidenceFileError("read", "too-large")); + return ok(decoded); } catch (cause: unknown) { return err(new EvidenceFileError("read", "io", { cause })); } }; -/** Atomically write bounded text to a regular file beneath an approved root. */ -export const writeBoundedText = async ( +/** Atomically write text to the caller-supplied path. */ +export const writeTextFile = async ( encoded: string, path: string, overwrite: boolean, - policy: EvidenceFilePolicy, ): Promise< Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError> > => { - if (policy.roots.length === 0) - return err(new EvidenceFileError("write", "disabled")); const bytes = Buffer.byteLength(encoded, "utf8"); - if (bytes > policy.maxBytes) - return err(new EvidenceFileError("write", "too-large")); try { const requestedPath = resolve(path); const canonicalParent = await realpath(dirname(requestedPath)); const destination = resolve(canonicalParent, basename(requestedPath)); - if (!(await isApproved(destination, policy.roots))) - return err(new EvidenceFileError("write", "outside-root")); const existing = await lstat(destination).catch((cause: unknown) => { if (fileErrorCode(cause) === "ENOENT") return undefined; throw cause; @@ -114,18 +85,6 @@ const publishNewFile = async ( } }; -const isApproved = async ( - candidate: string, - roots: readonly string[], -): Promise => { - for (const root of await canonicalizeConfiguredRoots( - roots.map((configuredRoot) => resolve(configuredRoot)), - )) { - if (isPathWithinRoot(root, candidate)) return true; - } - return false; -}; - const fileErrorCode = (cause: unknown): unknown => typeof cause === "object" && cause !== null && "code" in cause ? cause.code diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index 88baec11a..84153eda6 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -137,18 +137,14 @@ const deriveInventoryState = ( return "complete"; }; -const sortAndLimit = ( +const sortExclusions = ( exclusions: HistoricalSourceGraphInput["exclusions"], - maxExclusions: number, -): HistoricalSourceGraphInput["exclusions"] => { - const sorted = [...exclusions].sort((left, right) => { +): HistoricalSourceGraphInput["exclusions"] => + [...exclusions].sort((left, right) => { const byPath = compareUnicodeCodePoints(left.path, right.path); if (byPath !== 0) return byPath; return compareUnicodeCodePoints(left.reason, right.reason); }); - if (sorted.length <= maxExclusions) return sorted; - return sorted.slice(0, maxExclusions); -}; /** * Import a reference source directory into a committed historical source graph. @@ -163,7 +159,7 @@ export const importReferenceSource = async ( if (isAborted(options.signal)) return err(cancelled()); const prepared = await prepareReferenceSourceImport(options); if (!prepared.ok) return prepared; - const { ignored, limits, root, secrets } = prepared.value; + const { ignored, root, secrets } = prepared.value; if (isAborted(options.signal)) return err(cancelled()); const exclusions: HistoricalSourceGraphInput["exclusions"] = []; @@ -180,7 +176,7 @@ export const importReferenceSource = async ( }; const [readResult, vcs] = await Promise.all([ - readReferenceSource(root, limits, { + readReferenceSource(root, { ...(options.signal === undefined ? {} : { signal: options.signal }), shouldExclude, }), @@ -190,12 +186,7 @@ export const importReferenceSource = async ( if (!readResult.ok) { const error = readResult.error; if (error.code === "cancelled") return err(cancelled()); - return err( - failure( - error.code === "invalid-limits" ? "invalid-limits" : "io", - error.message, - ), - ); + return err(failure("io", error.message)); } if (isAborted(options.signal)) return err(cancelled()); @@ -218,7 +209,7 @@ export const importReferenceSource = async ( const sortedEntries = [...entries].sort((left, right) => compareUnicodeCodePoints(left.path, right.path), ); - const sortedExclusions = sortAndLimit(exclusions, limits.maxEntries); + const sortedExclusions = sortExclusions(exclusions); const sortedLimitations = [...limitations].sort(compareUnicodeCodePoints); const input: HistoricalSourceGraphInput = { diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 8b5f830c5..168a2f698 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -26,8 +26,6 @@ export const projectReferenceSourceEntryFailure = ( ): string => { if (entry.code === "cancelled") return "This entry was not read because the import was cancelled. Start the import again when ready."; - if (entry.code === "limit") - return "This entry was not read because an import limit was reached. Import a smaller directory or raise the configured limits."; if (entry.code === "unsupported") return "This entry cannot be read safely on this system. Exclude it or import the directory on a supported system."; if (entry.kind === "directory") @@ -50,7 +48,7 @@ const failedEntry = ( path: entry.path, kind: "directory", classifications, - tree_state: entry.code === "limit" ? "partial" : "unreadable", + tree_state: "unreadable", limitations: [limitation], }; if (entry.kind === "symlink") @@ -69,7 +67,7 @@ const failedEntry = ( size: entry.size ?? null, language: null, classifications: entry.kind === "file" ? classifications : ["unknown"], - content_state: entry.code === "limit" ? "too-large" : "unreadable", + content_state: "unreadable", limitations: [limitation], }; }; diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index 05bffbd0d..48aab577d 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -4,20 +4,15 @@ import { join, resolve } from "node:path"; import ignore from "ignore"; import { err, ok, type Result } from "../domain/result.js"; -import type { ReferenceSourceLimits } from "../reference/ReferenceSourceReader.js"; -import { isPathWithinRoot } from "../domain/localPath.js"; import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS, - DEFAULT_REFERENCE_SOURCE_LIMITS, type ReferenceSourceImportError, type ReferenceSourceImportOptions, } from "./ReferenceSourceImportTypes.js"; -import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js"; -/** Validated, authorized inputs ready for filesystem traversal. */ +/** Validated inputs ready for filesystem traversal. */ export interface PreparedReferenceSourceImport { readonly root: string; - readonly limits: ReferenceSourceLimits; readonly ignored: ReturnType; readonly secrets: ReturnType; } @@ -31,52 +26,16 @@ const failure = ( message, }); -const validLimits = (limits: ReferenceSourceLimits): boolean => - Object.values(limits).every( - (value) => Number.isSafeInteger(value) && value > 0, - ); - -const maximumLimits = ( - options: ReferenceSourceImportOptions, -): ReferenceSourceLimits => ({ - maxBytes: options.policy.maxBytes, - maxEntries: options.policy.maxEntries, - maxDepth: options.policy.maxDepth, - maxPathBytes: options.policy.maxPathBytes, -}); - -const clampLimits = ( - requested: ReferenceSourceLimits, - maximum: ReferenceSourceLimits, -): ReferenceSourceLimits => ({ - maxBytes: Math.min(requested.maxBytes, maximum.maxBytes), - maxEntries: Math.min(requested.maxEntries, maximum.maxEntries), - maxDepth: Math.min(requested.maxDepth, maximum.maxDepth), - maxPathBytes: Math.min(requested.maxPathBytes, maximum.maxPathBytes), -}); - -const authorizeRoot = async ( +const resolveRoot = async ( requestedRoot: string, - approvedRoots: readonly string[], ): Promise> => { - if (approvedRoots.length === 0) - return err(failure("policy", "No approved reference source roots")); try { if (!(await stat(requestedRoot)).isDirectory()) return err( failure("invalid-root", "Reference source root is not a directory"), ); const canonicalRoot = await realpath(resolve(requestedRoot)); - const canonicalApprovedRoots = await canonicalizeConfiguredRoots( - approvedRoots.map((approvedRoot) => resolve(approvedRoot)), - ); - for (const canonicalApproved of canonicalApprovedRoots) { - if (isPathWithinRoot(canonicalApproved, canonicalRoot)) - return ok(canonicalRoot); - } - return err( - failure("policy", "Reference source root is outside approved roots"), - ); + return ok(canonicalRoot); } catch { return err( failure("invalid-root", "Reference source root could not be resolved"), @@ -102,23 +61,16 @@ const buildIgnored = async ( return ignored; }; -/** Validate limits, authorize root, and build non-executing path filters. */ +/** Resolve the caller-selected directory and build path filters. */ export const prepareReferenceSourceImport = async ( options: ReferenceSourceImportOptions, ): Promise< Result > => { - const requested = options.limits ?? DEFAULT_REFERENCE_SOURCE_LIMITS; - const maximum = maximumLimits(options); - if (!validLimits(requested) || !validLimits(maximum)) - return err( - failure("invalid-limits", "Import limits must be positive integers"), - ); - const root = await authorizeRoot(options.root, options.policy.roots); + const root = await resolveRoot(options.root); if (!root.ok) return root; return ok({ root: root.value, - limits: clampLimits(requested, maximum), ignored: await buildIgnored(root.value, options.excludePaths ?? []), secrets: ignore().add([...options.policy.secretPatterns]), }); diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index 09c656dc0..a07ceb71f 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -1,16 +1,9 @@ import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; -import type { ReferenceSourceLimits } from "../reference/ReferenceSourceReader.js"; /** Typed expected failure returned by historical-source imports. */ export interface ReferenceSourceImportError { readonly tag: "reference-source-import"; - readonly code: - | "cancelled" - | "invalid-limits" - | "invalid-root" - | "io" - | "parse" - | "policy"; + readonly code: "cancelled" | "invalid-root" | "io" | "parse"; readonly message: string; } @@ -24,24 +17,12 @@ export const projectReferenceSourceImportError = ( message: "Reference source import was cancelled. Start it again when ready.", }; - if (error.code === "invalid-limits") - return { - category: "invalid_input", - message: - "Reference source limits are invalid. Use positive integer limits, then try again.", - }; if (error.code === "invalid-root") return { category: "invalid_input", message: "Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.", }; - if (error.code === "policy") - return { - category: "permission_required", - message: - "Reference source directory is not approved. Add its directory to `REA_REFERENCE_ROOTS_JSON`, restart REA, then try again.", - }; if (error.code === "io") return { category: "execution_failure", @@ -55,10 +36,9 @@ export const projectReferenceSourceImportError = ( }; }; -/** Bounded import request paired with mandatory operator policy. */ +/** Import a caller-selected local source tree as historical reference. */ export interface ReferenceSourceImportOptions { readonly root: string; - readonly limits?: ReferenceSourceLimits; readonly signal?: AbortSignal; readonly caller: string; readonly policy: ReferenceSourcePolicy; @@ -79,12 +59,5 @@ export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [ "*.log", ] as const; -export const DEFAULT_REFERENCE_SOURCE_LIMITS: ReferenceSourceLimits = { - maxBytes: 16 * 1024 * 1024, - maxEntries: 10_000, - maxDepth: 32, - maxPathBytes: 4_096, -}; - export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet = new Set(["JavaScript", "TypeScript", "JSX", "TSX"]); diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index b4146dfb5..003648606 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -7,13 +7,8 @@ import { } from "../application/DirectAnalysis.js"; import { importReferenceSource } from "../application/ReferenceSourceImport.js"; import { projectReferenceSourceImportError } from "../application/ReferenceSourceImportTypes.js"; -import { loadConfiguredPermissionAuthority } from "../application/PermissionConfiguration.js"; import { parseConfig } from "../config.js"; -import { - AnalysisProtocolError, - PermissionRequiredError, - projectAnalysisError, -} from "../domain/errors.js"; +import { projectAnalysisError } from "../domain/errors.js"; import { PRODUCT_IDENTITY } from "../identity.js"; import { logCliCommand } from "../cliLogging.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -110,11 +105,11 @@ const registerReferenceSourceCommand = ( logger: Logger, ): void => { cli.command(CLI_COMMANDS.importReferenceSource, { - description: "Import a bounded source tree as historical reference only", + description: "Import a source tree as historical reference only", args: z.object({ root: z .string() - .describe("Source root allowed by REA_REFERENCE_ROOTS_JSON"), + .describe("Local source directory to import as historical reference"), }), run: ({ args }) => logCliCommand(logger, "import-reference-source", async () => { @@ -124,36 +119,6 @@ const registerReferenceSourceCommand = ( error: "Import failed", ...projectAnalysisError(config.error), }; - const authority = await loadConfiguredPermissionAuthority(config.value); - if (!authority.ok) - return { - error: "Import failed", - ...projectAnalysisError(authority.error), - }; - const authorized = await authority.value.authorize( - { - capability: "reference_read", - roots: [args.root], - executables: [], - environment_names: [], - network: "none", - mount: false, - operation_identity: `import_reference_source:${args.root}`, - }, - "read", - ); - if (!authorized.ok) { - const error = - authorized.error instanceof PermissionRequiredError - ? authorized.error - : new AnalysisProtocolError(authorized.error.message, { - cause: authorized.error, - }); - return { - error: "Import failed", - ...projectAnalysisError(error), - }; - } const imported = await importReferenceSource({ root: args.root, caller: "rea-cli", diff --git a/src/cliApplicationCommands.ts b/src/cliApplicationCommands.ts index ce6ed8cc4..c2e00c16a 100644 --- a/src/cliApplicationCommands.ts +++ b/src/cliApplicationCommands.ts @@ -29,7 +29,6 @@ import { buildReconstructionObligationLedgerEvidenceValidated, resolveReconstructionObligationLedgerRequest, } from "./application/ReconstructionObligationLedgerService.js"; -import { authorizeRootPermission } from "./application/DeferredFileAuthorization.js"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; import { parseCliJsonInput } from "./cliJsonInput.js"; @@ -297,7 +296,7 @@ const registerJsonCommand = ({ logCliCommand(logger, name, async () => { const input = await parseCliJsonInput(args.inputJson, name); if (!input.ok) return input.error; - const lookup = await loadEvidenceLookup(options.evidenceBundle, name); + const lookup = await loadEvidenceLookup(options.evidenceBundle); if (!lookup.ok) return { error: "Application workflow failed", @@ -322,25 +321,12 @@ const registerJsonCommand = ({ const loadEvidenceLookup = async ( bundlePath: string | undefined, - operation: string, ): Promise< | { readonly ok: true; readonly value: EvidenceLookup | undefined } | { readonly ok: false; readonly error: AnalysisError } > => { if (bundlePath === undefined) return { ok: true, value: undefined }; - const configured = await loadConfiguredAuthority(); - if (!configured.ok) return configured; - const authorized = await authorizeRootPermission(configured.authority, { - capability: "evidence_read", - roots: [bundlePath], - access: "read", - operation, - }); - if (!authorized.ok) return authorized; - const loaded = await readEvidenceBundle( - bundlePath, - configured.config.evidenceFilePolicy, - ); + const loaded = await readEvidenceBundle(bundlePath); if (!loaded.ok) return loaded; const records = new Map( loaded.value.records.map((record) => [record.evidence_id, record]), diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 0e4bcf185..75557969d 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -5,21 +5,13 @@ import { exportEvidenceBundleCommand, importEvidenceBundleCommand, } from "./application/EvidenceBundleCommands.js"; -import { parseConfig } from "./config.js"; import { logCliCommand } from "./cliLogging.js"; import type { Logger } from "./logger.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; import type { JsonValue } from "./domain/jsonValue.js"; -import { - AnalysisProtocolError, - PermissionRequiredError, - projectAnalysisError, - type AnalysisError, -} from "./domain/errors.js"; -import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; -import type { AppConfig } from "./config.js"; +import { projectAnalysisError, type AnalysisError } from "./domain/errors.js"; -/** Register filesystem-gated Evidence commands. */ +/** Register caller-path Evidence commands. */ export const registerEvidenceCommands = ( cli: ReturnType, logger: Logger, @@ -31,16 +23,7 @@ export const registerEvidenceCommands = ( }), run: ({ args }) => logCliCommand(logger, "evidence-import", async () => { - const config = parseConfig(process.env); - if (!config.ok) return cliError(config.error); - const denied = await authorizeEvidence(config.value, [ - { capability: "evidence_read", path: args.path, access: "read" }, - ]); - if (denied !== undefined) return cliError(denied); - const imported = await importEvidenceBundleCommand( - args.path, - config.value.evidenceFilePolicy, - ); + const imported = await importEvidenceBundleCommand(args.path); return imported.ok ? imported.value : cliError(imported.error); }), }); @@ -55,18 +38,10 @@ export const registerEvidenceCommands = ( }), run: ({ args, options }) => logCliCommand(logger, "evidence-export", async () => { - const config = parseConfig(process.env); - if (!config.ok) return cliError(config.error); - const denied = await authorizeEvidence(config.value, [ - { capability: "evidence_read", path: args.source, access: "read" }, - { capability: "evidence_write", path: args.output, access: "write" }, - ]); - if (denied !== undefined) return cliError(denied); const exported = await exportEvidenceBundleCommand( args.source, args.output, options.overwrite, - config.value.evidenceFilePolicy, ); return exported.ok ? exported.value : cliError(exported.error); }), @@ -80,56 +55,15 @@ export const registerEvidenceCommands = ( }), run: ({ args }) => logCliCommand(logger, "compare", async () => { - const config = parseConfig(process.env); - if (!config.ok) return cliError(config.error); - const denied = await authorizeEvidence(config.value, [ - { capability: "evidence_read", path: args.left, access: "read" }, - { capability: "evidence_read", path: args.right, access: "read" }, - ]); - if (denied !== undefined) return cliError(denied); const compared = await compareEvidenceBundlesCommand({ leftPath: args.left, rightPath: args.right, - policy: config.value.evidenceFilePolicy, }); return compared.ok ? compared.value : cliError(compared.error); }), }); }; -const authorizeEvidence = async ( - config: AppConfig, - requests: readonly { - readonly capability: "evidence_read" | "evidence_write"; - readonly path: string; - readonly access: "read" | "write"; - }[], -): Promise => { - const authority = await loadConfiguredPermissionAuthority(config); - if (!authority.ok) return authority.error; - for (const request of requests) { - const result = await authority.value.authorize( - { - capability: request.capability, - roots: [request.path], - executables: [], - environment_names: [], - network: "none", - mount: false, - operation_identity: `cli:${request.capability}:${request.path}`, - }, - request.access, - ); - if (!result.ok) - return result.error instanceof PermissionRequiredError - ? result.error - : new AnalysisProtocolError(result.error.message, { - cause: result.error, - }); - } - return undefined; -}; - const cliError = (error: AnalysisError): JsonValue => ({ error: "Analysis failed", ...projectAnalysisError(error), diff --git a/src/config/environment.ts b/src/config/environment.ts index 9f45b2a43..405762ab0 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -46,9 +46,6 @@ const environmentSchema = z REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"), REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"), REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"), - REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"), - REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: z.string().default("[]"), - REA_REFERENCE_ROOTS_JSON: z.string().default("[]"), REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"), REA_BROWSER_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"), REA_BROWSER_CDP_ENDPOINTS_JSON: z.string().default("[]"), diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 77a98d2f1..037fc6bb2 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -6,11 +6,7 @@ import { parseAbsoluteRoots, parseLoaderArgs, } from "./parsers.js"; -import { - filePolicy, - permissionScope, - administratorGrants, -} from "./permissions.js"; +import { permissionScope, administratorGrants } from "./permissions.js"; import type { AppConfig } from "./types.js"; import type { PermissionCeiling } from "../domain/permissionPolicy.js"; import { @@ -53,9 +49,6 @@ const defaultHopperLauncherPath = (): string => interface ParsedArrays { readonly loaderArgs: readonly string[]; - readonly evidenceRoots: readonly string[]; - readonly analysisSnapshotRoots: readonly string[]; - readonly referenceRoots: readonly string[]; readonly secretPatterns: readonly string[]; readonly managedRuntimeRoots: readonly string[]; } @@ -74,21 +67,6 @@ const parseAllArrays = ( ): Result => { const loaderArgs = parseLoaderArgs(env.HOPPER_LOADER_ARGS_JSON); if (!loaderArgs.ok) return loaderArgs; - const evidenceRoots = parseStringArray( - env.REA_EVIDENCE_ROOTS_JSON, - "REA_EVIDENCE_ROOTS_JSON", - ); - if (!evidenceRoots.ok) return evidenceRoots; - const analysisSnapshotRoots = parseStringArray( - env.REA_ANALYSIS_SNAPSHOT_ROOTS_JSON, - "REA_ANALYSIS_SNAPSHOT_ROOTS_JSON", - ); - if (!analysisSnapshotRoots.ok) return analysisSnapshotRoots; - const referenceRoots = parseStringArray( - env.REA_REFERENCE_ROOTS_JSON, - "REA_REFERENCE_ROOTS_JSON", - ); - if (!referenceRoots.ok) return referenceRoots; const secretPatterns = parseStringArray( env.REA_REFERENCE_SECRET_PATTERNS_JSON, "REA_REFERENCE_SECRET_PATTERNS_JSON", @@ -101,9 +79,6 @@ const parseAllArrays = ( if (!managedRuntimeRoots.ok) return managedRuntimeRoots; return ok({ loaderArgs: loaderArgs.value, - evidenceRoots: evidenceRoots.value, - analysisSnapshotRoots: analysisSnapshotRoots.value, - referenceRoots: referenceRoots.value, secretPatterns: secretPatterns.value, managedRuntimeRoots: managedRuntimeRoots.value, }); @@ -123,12 +98,7 @@ const buildPermissionCeilings = ( policies: ParsedPolicies, ): readonly PermissionCeiling[] => { const ceilings: PermissionCeiling[] = [ - permissionScope("evidence_read", arrays.evidenceRoots), - permissionScope("evidence_write", arrays.evidenceRoots), - permissionScope("snapshot_read", arrays.analysisSnapshotRoots), - permissionScope("snapshot_write", arrays.analysisSnapshotRoots), permissionScope("artifact_extract", ["/"]), - permissionScope("reference_read", arrays.referenceRoots), ]; appendProcessCaptureCeiling(ceilings, policies.processCapture); appendPassiveObservationCeilings(ceilings, policies.passiveObservation); @@ -159,15 +129,8 @@ const buildAppConfig = ( artifactNativeMountEnabled: env.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true", artifactIntegrityContinueEnabled: env.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED === "true", - evidenceFilePolicy: filePolicy(arrays.evidenceRoots), - analysisSnapshotFilePolicy: filePolicy(arrays.analysisSnapshotRoots), referenceSourcePolicy: { - roots: arrays.referenceRoots, secretPatterns: arrays.secretPatterns, - maxBytes: 16 * 1024 * 1024, - maxEntries: 10_000, - maxDepth: 32, - maxPathBytes: 4_096, }, browserObservationPolicy: policies.passiveObservation.browser, browserScenarioPolicy: policies.browserScenario, diff --git a/src/config/permissions.ts b/src/config/permissions.ts index 2a8b7302d..78e9a3d5e 100644 --- a/src/config/permissions.ts +++ b/src/config/permissions.ts @@ -1,18 +1,9 @@ import { isLiteralLoopbackHostname } from "../domain/browserObservation.js"; -import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; import type { PermissionCeiling, PermissionGrant, } from "../domain/permissionPolicy.js"; -export const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({ - roots, - maxBytes: 64 * 1024 * 1024, - maxDepth: 64, - maxStringLength: 1024 * 1024, - maxNodes: 1_000_000, -}); - export const permissionScope = ( capability: PermissionCeiling["capability"], roots: readonly string[], diff --git a/src/config/types.ts b/src/config/types.ts index a751f21a5..14d61419f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,6 +1,5 @@ import type { LogLevel } from "../logger.js"; import type { ProcessExecutionPolicy } from "../domain/processCapture.js"; -import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; import type { PermissionCeiling, @@ -30,8 +29,6 @@ export interface AppConfig { readonly processExecutionPolicy: ProcessExecutionPolicy; readonly artifactNativeMountEnabled: boolean; readonly artifactIntegrityContinueEnabled: boolean; - readonly evidenceFilePolicy: EvidenceFilePolicy; - readonly analysisSnapshotFilePolicy: EvidenceFilePolicy; readonly referenceSourcePolicy: ReferenceSourcePolicy; readonly browserObservationPolicy: BrowserObservationPolicy; readonly browserScenarioPolicy: BrowserScenarioPolicy; diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index 1207b3bc7..96372767e 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { type AnalysisSnapshot, + analysisSnapshotSchema, snapshotBinding, snapshotEvidenceForQuery, snapshotTarget, @@ -15,6 +16,32 @@ import { createEvidence } from "./evidence.js"; import { createEvidenceBundle } from "./evidenceBundle.js"; describe("analysis snapshot contract", () => { + it("accepts snapshots with more than ten thousand analysis entries", () => { + const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE); + const entry = { + query_id: `query_${"0".repeat(64)}`, + operation: "analyze_function", + parameters: {}, + execution: { + result: null, + raw_result: null, + provider: binding.provider, + limitations: [], + locations: [], + subject: null, + }, + }; + + const parsed = analysisSnapshotSchema.parse({ + target: snapshotTarget(ANALYSIS_SNAPSHOT_TARGET), + binding, + entries: Array.from({ length: 10_001 }, () => entry), + evidence_bundle: createEvidenceBundle([]), + }); + + expect(parsed.entries).toHaveLength(10_001); + }); + it("finds only Evidence committed to the exact binding and profile", () => { const evidence = createEvidence( ANALYSIS_SNAPSHOT_TARGET, diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 4654de316..1c4059ec1 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -103,7 +103,7 @@ const entrySchema = z.object({ export const analysisSnapshotSchema = z.object({ target: targetSchema, binding: bindingSchema, - entries: z.array(entrySchema).max(10_000), + entries: z.array(entrySchema), evidence_bundle: evidenceBundleSchema, }); diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index ef1b65685..665ccad7b 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -45,14 +45,6 @@ export const evidenceBundleSchema = z.object({ export type EvidenceBundle = z.infer; -export interface EvidenceFilePolicy { - readonly roots: readonly string[]; - readonly maxBytes: number; - readonly maxDepth: number; - readonly maxStringLength: number; - readonly maxNodes: number; -} - /** Project records into a deterministic bundle whose order has no semantics. */ export const createEvidenceBundle = ( records: readonly Evidence[], diff --git a/src/domain/permissionPolicy.test.ts b/src/domain/permissionPolicy.test.ts index eebc3d876..83d72abec 100644 --- a/src/domain/permissionPolicy.test.ts +++ b/src/domain/permissionPolicy.test.ts @@ -131,7 +131,7 @@ describe("permission policy", () => { describe("permission policy lifecycle", () => { it("consumes once grants and applies revocation and ceiling reload immediately", () => { const ceiling = { - capability: "evidence_write" as const, + capability: "process_capture" as const, roots: ["/workspace/evidence"], executables: [], environment_names: [], @@ -180,7 +180,7 @@ describe("permission policy lifecycle", () => { it("reports a missing scope when revoked grants cover a different root", () => { const ceiling = { - capability: "evidence_write" as const, + capability: "process_capture" as const, roots: ["/workspace"], executables: [], environment_names: [], diff --git a/src/domain/permissionPolicy.ts b/src/domain/permissionPolicy.ts index 051d6bf5d..a7c847869 100644 --- a/src/domain/permissionPolicy.ts +++ b/src/domain/permissionPolicy.ts @@ -10,13 +10,8 @@ export const PERMISSION_CAPABILITIES = [ "electron_observe", "electron_automate", "v8_inspector_observe", - "evidence_read", - "evidence_write", - "snapshot_read", - "snapshot_write", "artifact_extract", "native_mount", - "reference_read", "javascript_replay", "managed_runtime", ] as const; diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 5dc33b4a3..8d2796b65 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -5,13 +5,11 @@ import canonicalize from "canonicalize"; import { z } from "zod"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; -const MAX_PATH = 4_096; const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); -const boundedTextSchema = z.string().min(1).max(1_024); +const boundedTextSchema = z.string().min(1); const relativePathSchema = z .string() .min(1) - .max(MAX_PATH) .refine( (path) => !path.startsWith("/") && @@ -35,14 +33,14 @@ const classificationSchema = z.enum([ const entryBaseShape = { path: relativePathSchema, - classifications: z.array(classificationSchema).min(1).max(8), - limitations: z.array(boundedTextSchema).max(100), + classifications: z.array(classificationSchema).min(1), + limitations: z.array(boundedTextSchema), }; const sourceFileBaseShape = { ...entryBaseShape, kind: z.literal("file"), - language: z.string().min(1).max(100).nullable(), + language: z.string().min(1).nullable(), }; const sourceFileSchema = z.union([ @@ -60,7 +58,6 @@ const sourceFileSchema = z.union([ "redacted-secret", "excluded", "unreadable", - "too-large", "unknown", ]), }), @@ -81,7 +78,6 @@ const sourceDirectorySchema = z.strictObject({ const symlinkTargetSchema = z .string() .min(1) - .max(MAX_PATH) .refine( (target) => target === "" || @@ -112,7 +108,7 @@ const sourceEntrySchema = z.union([ const sourceRelationshipSchema = z.strictObject({ from_path: relativePathSchema, - to: z.string().min(1).max(MAX_PATH), + to: z.string().min(1), kind: z.enum(["imports", "requires", "references", "declares-module"]), resolution: z.enum(["internal", "external", "unresolved", "unknown"]), parse_state: z.enum(["parsed", "partial", "unknown"]), @@ -120,7 +116,7 @@ const sourceRelationshipSchema = z.strictObject({ const parseFailureSchema = z.strictObject({ path: relativePathSchema, - parser: z.string().min(1).max(100), + parser: z.string().min(1), reason: boundedTextSchema, }); @@ -162,23 +158,22 @@ const graphShape = { root_alias: z.literal("$REFERENCE_ROOT"), root_sha256: digestSchema, inventory_state: z.enum(["complete", "partial", "unknown"]), - entries: z.array(sourceEntrySchema).max(100_000), - relationships: z.array(sourceRelationshipSchema).max(200_000), - parse_failures: z.array(parseFailureSchema).max(10_000), - exclusions: z.array(exclusionSchema).max(100_000), - languages: z.array(z.string().min(1).max(100)).max(1_000), - manifests: z.array(relativePathSchema).max(10_000), + entries: z.array(sourceEntrySchema), + relationships: z.array(sourceRelationshipSchema), + parse_failures: z.array(parseFailureSchema), + exclusions: z.array(exclusionSchema), + languages: z.array(z.string().min(1)), + manifests: z.array(relativePathSchema), vcs: vcsSchema, provenance: z.strictObject({ - importer: z.string().min(1).max(100), - importer_version: z.string().min(1).max(100).nullable(), + importer: z.string().min(1), + importer_version: z.string().min(1).nullable(), caller: z .string() .min(1) - .max(200) .regex(/^[\w .:@/+-]+$/u), }), - limitations: z.array(boundedTextSchema).max(1_000), + limitations: z.array(boundedTextSchema), }; const historicalSourceGraphStructureSchema = z @@ -209,8 +204,8 @@ const historicalSourceManifestBaseSchema = z.strictObject({ graph_sha256: digestSchema, root_sha256: digestSchema, inventory_state: z.enum(["complete", "partial", "unknown"]), - entry_count: z.number().int().min(0).max(100_000), - relationship_count: z.number().int().min(0).max(200_000), + entry_count: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + relationship_count: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), }); export type HistoricalSourceGraph = z.infer; diff --git a/src/domain/referenceSourcePolicy.ts b/src/domain/referenceSourcePolicy.ts index baf57bd8f..e9c9cf323 100644 --- a/src/domain/referenceSourcePolicy.ts +++ b/src/domain/referenceSourcePolicy.ts @@ -1,9 +1,4 @@ -/** Operator-approved roots, exclusions, and hard caps for source imports. */ +/** Exclusions and maximum traversal sizes for historical source imports. */ export interface ReferenceSourcePolicy { - readonly roots: readonly string[]; readonly secretPatterns: readonly string[]; - readonly maxBytes: number; - readonly maxEntries: number; - readonly maxDepth: number; - readonly maxPathBytes: number; } diff --git a/src/main/reload.ts b/src/main/reload.ts index 7062d563f..8a842beec 100644 --- a/src/main/reload.ts +++ b/src/main/reload.ts @@ -55,14 +55,6 @@ export const registerConfigReload = (input: { return; } runtimeState.currentConfig = refreshed.value; - Object.assign( - runtimeState.evidencePolicy, - refreshed.value.evidenceFilePolicy, - ); - Object.assign( - runtimeState.snapshotPolicy, - refreshed.value.analysisSnapshotFilePolicy, - ); }) .catch(() => { serverLogger.error(MCP_PERMISSION_RELOAD_FAILED); diff --git a/src/main/state.ts b/src/main/state.ts index c3dcf7844..b31f5a1bf 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -1,20 +1,9 @@ import type { AppConfig } from "../config.js"; -import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; export interface RuntimeState { currentConfig: AppConfig; - evidencePolicy: EvidenceFilePolicy & { roots: string[] }; - snapshotPolicy: EvidenceFilePolicy & { roots: string[] }; } export const createRuntimeState = (config: AppConfig): RuntimeState => ({ currentConfig: config, - evidencePolicy: { - ...config.evidenceFilePolicy, - roots: [...config.evidenceFilePolicy.roots], - }, - snapshotPolicy: { - ...config.analysisSnapshotFilePolicy, - roots: [...config.analysisSnapshotFilePolicy.roots], - }, }); diff --git a/src/main/transport.ts b/src/main/transport.ts index 221ac1aa3..6ca69b64c 100644 --- a/src/main/transport.ts +++ b/src/main/transport.ts @@ -60,8 +60,6 @@ export const startMcpTransport = async ( logger: serverContext.logger, processPolicy: () => serverContext.runtimeState.currentConfig.processExecutionPolicy, - evidenceFilePolicy: serverContext.runtimeState.evidencePolicy, - analysisSnapshotFilePolicy: serverContext.runtimeState.snapshotPolicy, permissionAuthority: serverContext.permissionAuthority, ...optionalProviders, artifactIntegrityContinueEnabled: () => @@ -93,7 +91,6 @@ export const startMcpTransport = async ( const runtimeAvailability = (state: RuntimeState) => ({ processCaptureEnabled: state.currentConfig.processExecutionPolicy.status === "enabled", - evidenceFileRoots: state.currentConfig.evidenceFilePolicy.roots.length, browserObservationEnabled: state.currentConfig.browserObservationPolicy.status === "enabled", browserScenarioEnabled: diff --git a/src/reference/ReferenceSourceReader.ts b/src/reference/ReferenceSourceReader.ts index b48ff969e..a904560b2 100644 --- a/src/reference/ReferenceSourceReader.ts +++ b/src/reference/ReferenceSourceReader.ts @@ -2,7 +2,6 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { err, ok } from "../domain/result.js"; import { traverseDirectory } from "./ReferenceSourceReaderEntries.js"; import { - type ReferenceSourceLimits, type ReferenceSourceReaderOptions, type ReferenceSourceRead, type ReferenceSourceResult, @@ -16,24 +15,16 @@ import { export type { ReferenceSourceEntry, - ReferenceSourceLimits, ReferenceSourceRead, ReferenceSourceReaderOptions, } from "./ReferenceSourceReaderTypes.js"; -const DEFAULT_LIMITS: ReferenceSourceLimits = { - maxBytes: 16 * 1024 * 1024, - maxEntries: 10_000, - maxDepth: 32, - maxPathBytes: 4_096, -}; const PATH_RACE_LIMITATION = "Path identity is revalidated around operations; Node lacks portable descriptor-relative openat traversal, so a syscall-boundary pathname race remains."; -/** Read a bounded source tree without intentionally following symbolic links. */ +/** Read a source tree without intentionally following symbolic links. */ export const readReferenceSource = async ( root: string, - limits: ReferenceSourceLimits = DEFAULT_LIMITS, options: ReferenceSourceReaderOptions = {}, ): Promise> => { if (!noFollowOpenSupported()) @@ -42,23 +33,19 @@ export const readReferenceSource = async ( code: "unsupported", message: "Safe no-follow file opens are unavailable", }); - const prepared = await prepareRoot(root, limits, options.signal); + const prepared = await prepareRoot(root, options.signal); if (!prepared.ok) return prepared; const { canonicalRoot, rootIdentity } = prepared.value; const state: TraversalState = { root: canonicalRoot, rootIdentity, - limits, ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.shouldExclude === undefined ? {} : { shouldExclude: options.shouldExclude }), entries: [], - pending: [{ path: canonicalRoot, depth: 0 }], + pending: [{ path: canonicalRoot }], bytesRead: 0, - filesSeen: 0, - truncated: false, - stopReason: undefined, }; const traversal = await traverse(state); if (!traversal.ok) return traversal; @@ -69,20 +56,14 @@ export const readReferenceSource = async ( root: canonicalRoot, entries: state.entries, bytesRead: state.bytesRead, - truncated: state.truncated, - limitations: [ - PATH_RACE_LIMITATION, - ...(state.stopReason === "entry-limit" - ? ["Traversal stopped because the entry limit was reached."] - : []), - ], + limitations: [PATH_RACE_LIMITATION], }); }; const traverse = async ( state: TraversalState, ): Promise> => { - while (state.pending.length > 0 && state.stopReason === undefined) { + while (state.pending.length > 0) { if (isAborted(state.signal)) return err({ tag: "reference-source-reader", diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index 3516ef750..4e4fc1f2f 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -81,7 +81,6 @@ export const traverseDirectory = async ( for (const name of names.value) { const result = await processEntry(state, current, name, directories); if (!result.ok) return result; - if (state.stopReason !== undefined) break; } directories.reverse(); state.pending.push(...directories); @@ -145,14 +144,6 @@ const processEntry = async ( }, }; if (excluded.value) return { ok: true, value: undefined }; - if (!reserveEntry(state)) return { ok: true, value: undefined }; - if (Buffer.byteLength(path) > state.limits.maxPathBytes) { - state.entries.push( - entryFailure(path, "unknown", "limit", "Path exceeds maxPathBytes"), - ); - state.truncated = true; - return { ok: true, value: undefined }; - } const metadata = await readMetadata(absolute); if (!metadata.ok) { state.entries.push(entryFailure(path, "unknown", "io", metadata.message)); @@ -170,15 +161,8 @@ const processEntry = async ( if (metadata.value.isSymbolicLink()) state.entries.push(await describeSymlink(state.root, absolute, path)); else if (metadata.value.isDirectory()) { - if (current.depth < state.limits.maxDepth) { - state.entries.push({ status: "read", kind: "directory", path }); - directories.push({ path: absolute, depth: current.depth + 1 }); - } else { - state.entries.push( - entryFailure(path, "directory", "limit", "Directory exceeds maxDepth"), - ); - state.truncated = true; - } + state.entries.push({ status: "read", kind: "directory", path }); + directories.push({ path: absolute }); } else if (!metadata.value.isFile()) state.entries.push( entryFailure( @@ -199,33 +183,16 @@ const processFileEntry = async ( path: string, metadata: BigStats, ): Promise => { - const remaining = state.limits.maxBytes - state.bytesRead; - if (metadata.size > BigInt(remaining)) { - state.entries.push( - entryFailure( - path, - "file", - "limit", - "File exceeds remaining maxBytes", - safeSize(metadata.size), - ), - ); - state.truncated = true; - return; - } const result = await readStableFile({ root: state.root, rootIdentity: state.rootIdentity, absolute, path, expected: metadata, - remaining, ...(state.signal === undefined ? {} : { signal: state.signal }), }); if (result.status === "read" && result.kind === "file") state.bytesRead += result.bytes.byteLength; - else if (result.status === "failed" && result.code === "limit") - state.truncated = true; state.entries.push(result); }; @@ -291,13 +258,3 @@ const applyExclusion = ( return { ok: false }; } }; - -const reserveEntry = (state: TraversalState): boolean => { - if (state.filesSeen < state.limits.maxEntries) { - state.filesSeen += 1; - return true; - } - state.truncated = true; - state.stopReason = "entry-limit"; - return false; -}; diff --git a/src/reference/ReferenceSourceReaderFile.ts b/src/reference/ReferenceSourceReaderFile.ts index d49ba946f..18fbf263a 100644 --- a/src/reference/ReferenceSourceReaderFile.ts +++ b/src/reference/ReferenceSourceReaderFile.ts @@ -75,10 +75,9 @@ const prepareFileRead = async ( const readFileContents = async (request: { readonly handle: FileHandle; readonly path: string; - readonly remaining: number; readonly signal?: AbortSignal; }): Promise => { - const { handle, path, remaining, signal } = request; + const { handle, path, signal } = request; const chunks: Buffer[] = []; let total = 0; for (;;) { @@ -93,20 +92,8 @@ const readFileContents = async (request: { total, ), }; - const capacity = Math.min(READ_CHUNK_BYTES, remaining + 1 - total); - if (capacity <= 0) - return { - status: "failed", - entry: entryFailure( - path, - "file", - "limit", - "File exceeds remaining maxBytes", - total, - ), - }; - const chunk = Buffer.allocUnsafe(capacity); - const read = await handle.read(chunk, 0, capacity, null); + const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES); + const read = await handle.read(chunk, 0, chunk.byteLength, null); if (isAborted(signal)) return { status: "failed", @@ -120,17 +107,6 @@ const readFileContents = async (request: { }; if (read.bytesRead === 0) break; total += read.bytesRead; - if (total > remaining) - return { - status: "failed", - entry: entryFailure( - path, - "file", - "limit", - "File exceeds remaining maxBytes", - total, - ), - }; chunks.push(chunk.subarray(0, read.bytesRead)); } return { status: "ok", chunks, total }; @@ -184,8 +160,7 @@ const finalizeFileRead = async ( export const readStableFile = async ( request: StableFileRequest, ): Promise => { - const { root, rootIdentity, absolute, path, expected, remaining, signal } = - request; + const { root, rootIdentity, absolute, path, expected, signal } = request; let handle: FileHandle | undefined; try { const parentBefore = await validateDirectory( @@ -207,7 +182,6 @@ export const readStableFile = async ( const contents = await readFileContents({ handle, path, - remaining, ...(signal === undefined ? {} : { signal }), }); if (contents.status === "failed") return contents.entry; diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts index 8a78e23bc..92c517c49 100644 --- a/src/reference/ReferenceSourceReaderTypes.ts +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -2,13 +2,6 @@ import { type BigIntStats } from "node:fs"; import { type Result } from "../domain/result.js"; -export interface ReferenceSourceLimits { - readonly maxBytes: number; - readonly maxEntries: number; - readonly maxDepth: number; - readonly maxPathBytes: number; -} - export interface ReferenceSourceReaderOptions { readonly signal?: AbortSignal; readonly shouldExclude?: (path: string) => boolean; @@ -18,7 +11,6 @@ export type ReferenceSourceFailureCode = | "cancelled" | "changed" | "io" - | "limit" | "symlink" | "unsupported"; @@ -55,19 +47,13 @@ export interface ReferenceSourceRead { readonly root: string; readonly entries: readonly ReferenceSourceEntry[]; readonly bytesRead: number; - readonly truncated: boolean; /** Node exposes no portable openat traversal; pathname identity is checked around each operation. */ readonly limitations: readonly string[]; } export interface ReferenceSourceReaderError { readonly tag: "reference-source-reader"; - readonly code: - | "cancelled" - | "invalid-limits" - | "invalid-root" - | "io" - | "unsupported"; + readonly code: "cancelled" | "invalid-root" | "io" | "unsupported"; readonly message: string; } @@ -75,21 +61,16 @@ export type BigStats = BigIntStats; export type PendingDirectory = { readonly path: string; - readonly depth: number; }; export type TraversalState = { readonly root: string; readonly rootIdentity: BigStats; - readonly limits: ReferenceSourceLimits; readonly signal?: AbortSignal; readonly shouldExclude?: (path: string) => boolean; readonly entries: ReferenceSourceEntry[]; readonly pending: PendingDirectory[]; bytesRead: number; - filesSeen: number; - truncated: boolean; - stopReason: "entry-limit" | undefined; }; export type StableFileRequest = { @@ -98,7 +79,6 @@ export type StableFileRequest = { readonly absolute: string; readonly path: string; readonly expected: BigStats; - readonly remaining: number; readonly signal?: AbortSignal; }; diff --git a/src/reference/ReferenceSourceReaderValidate.ts b/src/reference/ReferenceSourceReaderValidate.ts index bedddade4..30516b587 100644 --- a/src/reference/ReferenceSourceReaderValidate.ts +++ b/src/reference/ReferenceSourceReaderValidate.ts @@ -7,7 +7,6 @@ import { isPathWithinRoot } from "../domain/localPath.js"; import { type BigStats, type ReferenceSourceFailureCode, - type ReferenceSourceLimits, type ReferenceSourceResult, } from "./ReferenceSourceReaderTypes.js"; @@ -24,11 +23,6 @@ export const sameFile = (left: BigStats, right: BigStats): boolean => export const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; -const validLimits = (limits: ReferenceSourceLimits): boolean => - Object.values(limits).every( - (value) => Number.isSafeInteger(value) && value > 0, - ); - export const noFollowOpenSupported = (): boolean => Number.isSafeInteger(constants.O_NOFOLLOW) && constants.O_NOFOLLOW !== 0; @@ -80,7 +74,6 @@ export const validateDirectory = async ( export const prepareRoot = async ( root: string, - limits: ReferenceSourceLimits, signal?: AbortSignal, ): Promise< ReferenceSourceResult<{ @@ -88,10 +81,6 @@ export const prepareRoot = async ( readonly rootIdentity: BigStats; }> > => { - if (!validLimits(limits)) - return err( - failure("invalid-limits", "Reader limits must be positive integers"), - ); if (isAborted(signal)) return err(cancelled()); try { const metadata = await bigLstat(root); diff --git a/tests/boundary/cli/cliPolicyCommands.test.ts b/tests/boundary/cli/cliPolicyCommands.test.ts index 225c0e87b..5a82abba6 100644 --- a/tests/boundary/cli/cliPolicyCommands.test.ts +++ b/tests/boundary/cli/cliPolicyCommands.test.ts @@ -77,7 +77,7 @@ describe("policy revocation approval", () => { const store = join(project, ".rea", "permissions.json"); const grant = { grant_id: "project:revoke-me", - capability: "evidence_read" as const, + capability: "managed_runtime" as const, roots: [project], executables: [], environment_names: [], diff --git a/tests/boundary/filesystem/analysisSnapshot.test.ts b/tests/boundary/filesystem/analysisSnapshot.test.ts index db4d6db65..fa47ff970 100644 --- a/tests/boundary/filesystem/analysisSnapshot.test.ts +++ b/tests/boundary/filesystem/analysisSnapshot.test.ts @@ -45,18 +45,11 @@ describe("analysis snapshots: persistence", () => { entries: [entry], evidence_bundle: createEvidenceBundle([]), }; - const policy = { - roots: [directory], - maxBytes: 1024 * 1024, - maxDepth: 64, - maxStringLength: 1024, - maxNodes: 10_000, - }; - const written = await writeAnalysisSnapshot(snapshot, path, false, policy); + const written = await writeAnalysisSnapshot(snapshot, path, false); expect(written.ok).toBe(true); expect((await readFile(path, "utf8")).endsWith("\n")).toBe(true); expect((await stat(path)).mode & 0o777).toBe(0o600); - expect((await readAnalysisSnapshot(path, policy)).ok).toBe(true); + expect((await readAnalysisSnapshot(path)).ok).toBe(true); const altered: unknown = JSON.parse(await readFile(path, "utf8")); if ( @@ -77,7 +70,7 @@ describe("analysis snapshots: persistence", () => { throw new TypeError("fixture entry is malformed"); Reflect.set(first.parameters, "address", "0x2000"); await writeFile(path, JSON.stringify(altered)); - const loaded = await readAnalysisSnapshot(path, policy); + const loaded = await readAnalysisSnapshot(path); expect(loaded.ok).toBe(false); if (!loaded.ok) expect(loaded.error._tag).toBe("EvidenceIntegrityError"); expect(() => parseAnalysisSnapshot(altered)).toThrow(/identifier/u); diff --git a/tests/boundary/filesystem/directAnalysisPermissions.test.ts b/tests/boundary/filesystem/directAnalysisPermissions.test.ts index e00af571f..1248d64d0 100644 --- a/tests/boundary/filesystem/directAnalysisPermissions.test.ts +++ b/tests/boundary/filesystem/directAnalysisPermissions.test.ts @@ -15,7 +15,6 @@ import { import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; -import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; import { REA_WORKFLOW_PROVIDER, workflowAnalysisProfile, @@ -27,7 +26,7 @@ afterEach(async () => { vi.unstubAllEnvs(); }); -describe("direct analysis snapshot filesystem permissions", () => { +describe("direct analysis snapshot files", () => { it("forwards the CLI provider selector ahead of the environment preference", async () => { const directory = await createTestTempDirectory("rea-direct-provider-"); const targetPath = join(directory, "fixture.hop"); @@ -55,7 +54,7 @@ describe("direct analysis snapshot filesystem permissions", () => { }); }); - it("replays a cache hit with snapshot read authority only", async () => { + it("replays a cache hit from the supplied path without root configuration", async () => { const directory = await createTestTempDirectory("rea-direct-snapshot-"); const snapshotPath = join(directory, "analysis.json"); const launcherPath = join(directory, "hopper-launcher"); @@ -84,22 +83,9 @@ describe("direct analysis snapshot filesystem permissions", () => { entries: [], evidence_bundle: createEvidenceBundle([evidence]), }; - const policy = { - roots: [directory], - maxBytes: 1024 * 1024, - maxDepth: 64, - maxStringLength: 1024, - maxNodes: 10_000, - }; expect( - (await writeAnalysisSnapshot(snapshot, snapshotPath, false, policy)).ok, + (await writeAnalysisSnapshot(snapshot, snapshotPath, false)).ok, ).toBe(true); - const authority = await permissionAuthorityForRoot( - directory, - ["snapshot_read", "snapshot_write"], - ["snapshot_read"], - ); - vi.stubEnv("REA_ANALYSIS_SNAPSHOT_ROOTS_JSON", JSON.stringify([directory])); vi.stubEnv("HOPPER_LAUNCHER_PATH", launcherPath); await expect( @@ -109,7 +95,6 @@ describe("direct analysis snapshot filesystem permissions", () => { {}, { snapshotPath, - permissionAuthority: authority, }, ), ).resolves.toEqual(evidence); diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index 414dcb606..e19ceee37 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -14,7 +14,6 @@ import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle, serializeEvidenceBundle, - type EvidenceFilePolicy, } from "../../../src/domain/evidenceBundle.js"; const bundle = (result = true) => @@ -26,46 +25,36 @@ const bundle = (result = true) => ), ]); -const policy = (root: string): EvidenceFilePolicy => ({ - roots: [root], - maxBytes: 1024 * 1024, - maxDepth: 64, - maxStringLength: 1024, - maxNodes: 10_000, -}); - describe("evidence bundle publication", () => { it("allows only one simultaneous export without overwrite approval", async () => { const root = await createTestTempDirectory("rea-evidence-exclusive-"); const path = join(root, "bundle.json"); const candidates = [bundle(), bundle(false)]; const results = await Promise.all( - candidates.map((value) => - writeEvidenceBundle(value, path, false, policy(root)), - ), + candidates.map((value) => writeEvidenceBundle(value, path, false)), ); expect(results.filter(({ ok }) => ok)).toHaveLength(1); expect(results.filter(({ ok }) => !ok)).toMatchObject([ { ok: false, error: { _tag: "EvidenceFileError", reason: "exists" } }, ]); const winner = candidates[results.findIndex(({ ok }) => ok)]; - expect(await readEvidenceBundle(path, policy(root))).toEqual({ + expect(await readEvidenceBundle(path)).toEqual({ ok: true, value: winner, }); expect(await readdir(root)).toEqual(["bundle.json"]); }); - it("round trips dot-prefixed child names beneath an approved root", async () => { + it("round trips caller-selected paths without configured roots", async () => { const root = await createTestTempDirectory("rea-evidence-dot-child-"); const directory = join(root, "..cache"); await mkdir(directory); const path = join(directory, "bundle.json"); const value = bundle(); - expect( - await writeEvidenceBundle(value, path, false, policy(root)), - ).toMatchObject({ ok: true }); - expect(await readEvidenceBundle(path, policy(root))).toEqual({ + expect(await writeEvidenceBundle(value, path, false)).toMatchObject({ + ok: true, + }); + expect(await readEvidenceBundle(path)).toEqual({ ok: true, value, }); @@ -77,17 +66,12 @@ describe("evidence bundle filesystem adapter", () => { const directory = await createTestTempDirectory("rea-evidence-"); const path = join(directory, "bundle.json"); const evidenceBundle = bundle(); - const first = await writeEvidenceBundle( - evidenceBundle, - path, - false, - policy(directory), - ); + const first = await writeEvidenceBundle(evidenceBundle, path, false); expect(first).toMatchObject({ ok: true, value: { path } }); expect(await readFile(path, "utf8")).toBe( serializeEvidenceBundle(evidenceBundle), ); - expect(await readEvidenceBundle(path, policy(directory))).toEqual({ + expect(await readEvidenceBundle(path)).toEqual({ ok: true, value: evidenceBundle, }); @@ -95,7 +79,6 @@ describe("evidence bundle filesystem adapter", () => { await compareEvidenceBundlesCommand({ leftPath: path, rightPath: path, - policy: policy(directory), }), ).toMatchObject({ ok: true, @@ -105,60 +88,39 @@ describe("evidence bundle filesystem adapter", () => { }, }); expect( - await writeEvidenceBundle(evidenceBundle, path, false, policy(directory)), + await writeEvidenceBundle(evidenceBundle, path, false), ).toMatchObject({ ok: false, error: { _tag: "EvidenceFileError", reason: "exists" }, }); - expect( - await writeEvidenceBundle(evidenceBundle, path, true, policy(directory)), - ).toMatchObject({ ok: true }); + expect(await writeEvidenceBundle(evidenceBundle, path, true)).toMatchObject( + { ok: true }, + ); }); - it("uses valid evidence roots when another configured root is missing", async () => { - const directory = await createTestTempDirectory("rea-evidence-roots-"); - const path = join(directory, "bundle.json"); - const configured = { - ...policy(directory), - roots: [join(directory, "missing"), directory], - }; - expect( - await writeEvidenceBundle(bundle(), path, false, configured), - ).toMatchObject({ ok: true }); - expect(await readEvidenceBundle(path, configured)).toMatchObject({ - ok: true, - }); - }); - - it("rejects traversal and symlink escape from approved roots", async () => { + it("reads the caller-selected symlink target and refuses to replace a symlink", async () => { const directory = await createTestTempDirectory("rea-evidence-"); - const root = join(directory, "approved"); const outside = join(directory, "outside"); - await mkdir(root); await mkdir(outside); const outsidePath = join(outside, "bundle.json"); await writeFile(outsidePath, serializeEvidenceBundle(bundle())); - const link = join(root, "escaped.json"); + const link = join(directory, "escaped.json"); await symlink(outsidePath, link); - expect(await readEvidenceBundle(link, policy(root))).toMatchObject({ - ok: false, - error: { _tag: "EvidenceFileError", reason: "outside-root" }, + expect(await readEvidenceBundle(link)).toMatchObject({ + ok: true, + value: bundle(), }); - expect( - await writeEvidenceBundle(bundle(), outsidePath, true, policy(root)), - ).toMatchObject({ + expect(await writeEvidenceBundle(bundle(), link, true)).toMatchObject({ ok: false, - error: { _tag: "EvidenceFileError", reason: "outside-root" }, + error: { _tag: "EvidenceFileError", reason: "not-file" }, }); }); - it("rejects malformed, tampered, oversized, and deeply nested input", async () => { + it("rejects malformed and tampered input", async () => { const directory = await createTestTempDirectory("rea-evidence-"); const malformed = join(directory, "malformed.json"); await writeFile(malformed, "{"); - expect( - await readEvidenceBundle(malformed, policy(directory)), - ).toMatchObject({ + expect(await readEvidenceBundle(malformed)).toMatchObject({ ok: false, error: { _tag: "EvidenceFileError", reason: "invalid-json" }, }); @@ -172,35 +134,9 @@ describe("evidence bundle filesystem adapter", () => { records: [{ ...tampered.records[0], normalized_result: "changed" }], }), ); - expect( - await readEvidenceBundle(tamperedPath, policy(directory)), - ).toMatchObject({ + expect(await readEvidenceBundle(tamperedPath)).toMatchObject({ ok: false, error: { _tag: "EvidenceIntegrityError" }, }); - - const oversized = join(directory, "oversized.json"); - await writeFile(oversized, "x".repeat(100)); - expect( - await readEvidenceBundle(oversized, { - ...policy(directory), - maxBytes: 10, - }), - ).toMatchObject({ - ok: false, - error: { _tag: "EvidenceFileError", reason: "too-large" }, - }); - - const deep = join(directory, "deep.json"); - await writeFile(deep, JSON.stringify({ one: { two: { three: null } } })); - expect( - await readEvidenceBundle(deep, { - ...policy(directory), - maxDepth: 1, - }), - ).toMatchObject({ - ok: false, - error: { _tag: "EvidenceFileError", reason: "too-large" }, - }); }); }); diff --git a/tests/boundary/filesystem/permissionAuthority.test.ts b/tests/boundary/filesystem/permissionAuthority.test.ts index 6b80d48a2..4d9172b9b 100644 --- a/tests/boundary/filesystem/permissionAuthority.test.ts +++ b/tests/boundary/filesystem/permissionAuthority.test.ts @@ -69,7 +69,7 @@ describe("permission authority filesystem boundaries", () => { await symlink(join(outside, "secret"), join(root, "escape")); const ceiling = await canonicalizePermissionCeilings([ { - capability: "evidence_read", + capability: "process_capture", roots: [join(sandbox, "root-alias")], executables: [], environment_names: [], @@ -90,7 +90,7 @@ describe("permission authority filesystem boundaries", () => { ]); const inside = await canonicalizePermissionRequest( { - capability: "evidence_read", + capability: "process_capture", roots: [join(root, "inside")], executables: [], environment_names: [], @@ -102,7 +102,7 @@ describe("permission authority filesystem boundaries", () => { ); const escaped = await canonicalizePermissionRequest( { - capability: "evidence_read", + capability: "process_capture", roots: [join(root, "escape")], executables: [], environment_names: [], @@ -128,7 +128,7 @@ describe("permission authority filesystem boundaries", () => { const sandbox = await createTestTempDirectory("rea-permission-write-"); const result = await canonicalizePermissionRequest( { - capability: "evidence_write", + capability: "process_capture", roots: [join(sandbox, "new.json")], executables: [], environment_names: [], @@ -319,7 +319,7 @@ describe("permission authority policy reload", () => { }); const evidenceScope = (root: string): PermissionScope => ({ - capability: "evidence_read", + capability: "process_capture", roots: [root], executables: [], environment_names: [], @@ -329,7 +329,7 @@ const evidenceScope = (root: string): PermissionScope => ({ const administratorGrant = (scope: PermissionScope): PermissionGrant => ({ ...scope, - grant_id: "administrator:evidence_read", + grant_id: "administrator:process_capture", lifetime: "administrator", operation_identity: null, expires_at: null, diff --git a/tests/boundary/filesystem/projectPermissionStore.test.ts b/tests/boundary/filesystem/projectPermissionStore.test.ts index 05dbd9469..94c5196f4 100644 --- a/tests/boundary/filesystem/projectPermissionStore.test.ts +++ b/tests/boundary/filesystem/projectPermissionStore.test.ts @@ -17,7 +17,7 @@ describe("project permission store", () => { const path = join(project, ".rea", "permissions.json"); const grant = { grant_id: "project:evidence-read", - capability: "evidence_read" as const, + capability: "process_capture" as const, roots: [project], executables: [], environment_names: [], @@ -122,7 +122,7 @@ describe("project permission store", () => { const path = join(project, ".rea", "permissions.json"); const grant = (grantId: string) => ({ grant_id: grantId, - capability: "evidence_read" as const, + capability: "process_capture" as const, roots: [project], executables: [], environment_names: [], diff --git a/tests/boundary/filesystem/projectPermissionStoreLocking.test.ts b/tests/boundary/filesystem/projectPermissionStoreLocking.test.ts index 114ebf4f4..2ac67dc8c 100644 --- a/tests/boundary/filesystem/projectPermissionStoreLocking.test.ts +++ b/tests/boundary/filesystem/projectPermissionStoreLocking.test.ts @@ -20,7 +20,7 @@ describe("permission store write ownership", () => { const result = await writeProjectPermissionStore(path, project, [ { grant_id: "project:new", - capability: "evidence_read", + capability: "process_capture", roots: [project], executables: [], environment_names: [], diff --git a/tests/boundary/filesystem/referenceSourceImport.test.ts b/tests/boundary/filesystem/referenceSourceImport.test.ts index 8e31622af..23f2bbd1a 100644 --- a/tests/boundary/filesystem/referenceSourceImport.test.ts +++ b/tests/boundary/filesystem/referenceSourceImport.test.ts @@ -18,13 +18,6 @@ import { } from "../../../src/application/ReferenceSourceImportTypes.js"; import { createHistoricalSourceManifest } from "../../../src/domain/referenceSourceGraph.js"; -const limits = { - maxBytes: 1024 * 1024, - maxEntries: 1_000, - maxDepth: 16, - maxPathBytes: 4_096, -}; - const fixture = async (parent: string, name: string): Promise => { const root = join(parent, name); await mkdir(join(root, "src"), { recursive: true }); @@ -38,20 +31,13 @@ const fixture = async (parent: string, name: string): Promise => { return root; }; -const importTree = ( - root: string, - approvedRoot: string | readonly string[], - signal?: AbortSignal, -) => +const importTree = (root: string, signal?: AbortSignal) => importReferenceSource({ root, caller: "reference-import-test", policy: { - roots: typeof approvedRoot === "string" ? [approvedRoot] : approvedRoot, secretPatterns: [".env", ".env.*"], - ...limits, }, - limits, ...(signal === undefined ? {} : { signal }), }); @@ -83,7 +69,6 @@ describe("reference source import error projection", () => { ["symlink", "io"], ["file", "io"], ["file", "cancelled"], - ["file", "limit"], ["unknown", "unsupported"], ] as const) { const message = projectReferenceSourceEntryFailure({ @@ -102,11 +87,9 @@ describe("reference source import error projection", () => { it("projects every import failure without raw parser or policy text", () => { const expectedCategories = { cancelled: "cancelled", - "invalid-limits": "invalid_input", "invalid-root": "invalid_input", io: "execution_failure", parse: "execution_failure", - policy: "permission_required", } as const; for (const [code, category] of Object.entries(expectedCategories)) { const projected = projectReferenceSourceImportError({ @@ -117,14 +100,54 @@ describe("reference source import error projection", () => { expect(projected.category).toBe(category); expect(projected.message).not.toContain("SECRET"); expect(projected.message).not.toContain("/private/path"); - expect(projected.message).toMatch( - /try again|when ready|positive integer|Check that|REA_REFERENCE_ROOTS_JSON/u, - ); + expect(projected.message).toMatch(/try again|when ready|Check that/u); } }); }); describe("reference source import behavior", () => { + it("imports a source file larger than the former 16 MiB ceiling", async () => { + const root = await createTestTempDirectory("rea-reference-large-"); + const size = 16 * 1024 * 1024 + 1; + await writeFile(join(root, "large.bin"), Buffer.alloc(size, 0x61)); + + const result = await importReferenceSource({ + root, + caller: "reference-import-test", + policy: { secretPatterns: [] }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries).toContainEqual( + expect.objectContaining({ + path: "large.bin", + kind: "file", + size, + content_state: "hashed", + }), + ); + }); + + it("imports more than ten thousand source entries", async () => { + const root = await createTestTempDirectory("rea-reference-many-"); + await Promise.all( + Array.from({ length: 10_001 }, (_, index) => + writeFile(join(root, `entry-${String(index).padStart(5, "0")}`), ""), + ), + ); + + const result = await importReferenceSource({ + root, + caller: "reference-import-test", + policy: { secretPatterns: [] }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries).toHaveLength(10_001); + }); + it("imports BMP and supplementary filenames in Unicode code point order", async () => { const root = await createTestTempDirectory("reference-unicode-"); try { @@ -134,11 +157,11 @@ describe("reference source import behavior", () => { writeFile(join(root, path), "export const value = 1;\n"), ), ); - const result = await importTree(root, root); + const result = await importTree(root); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.entries.map(({ path }) => path)).toEqual(paths); - const repeated = await importTree(root, root); + const repeated = await importTree(root); expect(repeated).toEqual(result); } finally { await rm(root, { recursive: true, force: true }); @@ -151,8 +174,8 @@ describe("reference source import behavior", () => { const leftRoot = await fixture(parent, "left"); const rightRoot = await fixture(parent, "right"); const [left, right] = await Promise.all([ - importTree(leftRoot, parent), - importTree(rightRoot, parent), + importTree(leftRoot), + importTree(rightRoot), ]); expect(left.ok).toBe(true); expect(right.ok).toBe(true); @@ -179,42 +202,20 @@ describe("reference source import behavior", () => { } }); - it("fails closed outside policy and honors cancellation", async () => { - const parent = await createTestTempDirectory("rea-reference-policy-"); + it("imports the caller-selected directory and honors cancellation", async () => { const outside = await createTestTempDirectory("rea-reference-outside-"); try { const root = await fixture(outside, "tree"); - const denied = await importTree(root, parent); - expect(denied).toMatchObject({ - ok: false, - error: { code: "policy" }, - }); + expect(await importTree(root)).toMatchObject({ ok: true }); const controller = new AbortController(); controller.abort(); - const cancelled = await importTree(root, outside, controller.signal); + const cancelled = await importTree(root, controller.signal); expect(cancelled).toMatchObject({ ok: false, error: { code: "cancelled" }, }); } finally { - await Promise.all([ - rm(parent, { recursive: true, force: true }), - rm(outside, { recursive: true, force: true }), - ]); - } - }); - - it("uses a valid reference root when another configured root is missing", async () => { - const parent = await createTestTempDirectory("rea-reference-roots-"); - try { - const root = await fixture(parent, "tree"); - const imported = await importTree(root, [ - join(parent, "missing"), - parent, - ]); - expect(imported).toMatchObject({ ok: true }); - } finally { - await rm(parent, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); } }); @@ -231,7 +232,7 @@ describe("reference source import behavior", () => { author: { name: "REA Test", email: "rea@example.invalid" }, message: "fixture", }); - const result = await importTree(root, parent); + const result = await importTree(root); expect(result.ok).toBe(true); if (!result.ok) throw result.error; expect(result.value.vcs).toEqual({ kind: "git", head: oid, dirty: null }); diff --git a/tests/boundary/filesystem/referenceSourceReader.test.ts b/tests/boundary/filesystem/referenceSourceReader.test.ts index 62345d242..23ff4abf2 100644 --- a/tests/boundary/filesystem/referenceSourceReader.test.ts +++ b/tests/boundary/filesystem/referenceSourceReader.test.ts @@ -7,13 +7,6 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js"; -const limits = { - maxBytes: 1_024, - maxEntries: 10, - maxDepth: 4, - maxPathBytes: 100, -} as const; - describe("readReferenceSource entries", () => { it("returns explicit entries in canonical code-point path order", async () => { const root = await createTestTempDirectory("rea-reference-"); @@ -24,7 +17,7 @@ describe("readReferenceSource entries", () => { await writeFile(join(root, "\u{e000}"), "p"); await writeFile(join(root, "\u{10000}"), "a"); - const result = await readReferenceSource(root, limits); + const result = await readReferenceSource(root); expect(result.ok).toBe(true); if (!result.ok) return; @@ -37,7 +30,6 @@ describe("readReferenceSource entries", () => { "𐀀", ]); expect(result.value.bytesRead).toBe(5); - expect(result.value.truncated).toBe(false); expect( result.value.entries.map((entry) => entry.status === "read" && entry.kind === "file" @@ -59,7 +51,7 @@ describe("readReferenceSource entries", () => { await symlink(join(outside, "secret"), join(root, "external")); await symlink("absent", join(root, "missing")); - const result = await readReferenceSource(root, limits); + const result = await readReferenceSource(root); expect(result.ok).toBe(true); if (!result.ok) return; @@ -97,56 +89,42 @@ describe("readReferenceSource entries", () => { expect(JSON.stringify(result.value.entries)).not.toContain(outside); }); - it("makes byte, file, depth, and path limits explicit per entry", async () => { - const root = await createTestTempDirectory("rea-reference-"); - await writeFile(join(root, "a"), "too large"); - await writeFile(join(root, "b"), "b"); - await mkdir(join(root, "deep")); - await writeFile(join(root, "deep", "c"), "c"); + it("reads a source file larger than the former 16 MiB ceiling", async () => { + const root = await createTestTempDirectory("rea-reference-large-"); + const size = 16 * 1024 * 1024 + 1; + await writeFile(join(root, "large.bin"), Buffer.alloc(size, 0x61)); - const result = await readReferenceSource(root, { - maxBytes: 1, - maxEntries: 1, - maxDepth: 1, - maxPathBytes: 4, - }); + const result = await readReferenceSource(root); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.truncated).toBe(true); - expect(result.value.entries).toEqual([ + expect(result.value.bytesRead).toBe(size); + expect(result.value.entries).toContainEqual( expect.objectContaining({ - path: "a", + status: "read", kind: "file", - status: "failed", - code: "limit", - size: 9, + path: "large.bin", + size, }), - ]); - expect(result.value.limitations).toContain( - "Traversal stopped because the entry limit was reached.", - ); - expect(result.value.entries).not.toContainEqual( - expect.objectContaining({ path: "" }), ); }); - it("applies maxEntries as a global limit across every entry kind", async () => { - const root = await createTestTempDirectory("rea-reference-"); - await writeFile(join(root, "a"), "a"); - await writeFile(join(root, "b"), "b"); - await writeFile(join(root, "c"), "c"); + it("traverses source directories deeper than the former depth ceiling", async () => { + const root = await createTestTempDirectory("rea-reference-deep-"); + const segments = Array.from({ length: 40 }, (_, index) => `d${index}`); + const directory = join(root, ...segments); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "deep.txt"), "deep"); - const result = await readReferenceSource(root, { - ...limits, - maxEntries: 1, - }); + const result = await readReferenceSource(root); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.entries.map(({ path }) => path)).toEqual(["a"]); - expect(result.value.bytesRead).toBe(1); - expect(result.value.truncated).toBe(true); + const path = [...segments, "deep.txt"].join("/"); + expect(result.value.entries).toContainEqual( + expect.objectContaining({ status: "read", kind: "file", path }), + ); + expect(result.value.entries).toHaveLength(41); }); }); @@ -158,7 +136,7 @@ describe("readReferenceSource failures and exclusions", () => { await writeFile(join(root, "kept"), "kept"); const checked: string[] = []; - const result = await readReferenceSource(root, limits, { + const result = await readReferenceSource(root, { shouldExclude: (path) => { checked.push(path); return path === "ignored"; @@ -174,7 +152,7 @@ describe("readReferenceSource failures and exclusions", () => { it("sanitizes exclusion callback failures", async () => { const root = await createTestTempDirectory("rea-reference-"); await writeFile(join(root, "file"), "value"); - const result = await readReferenceSource(root, limits, { + const result = await readReferenceSource(root, { shouldExclude: () => { throw new Error("private callback detail"); }, @@ -192,7 +170,7 @@ describe("readReferenceSource failures and exclusions", () => { it("sanitizes filesystem errors", async () => { const missing = join(tmpdir(), "rea-secret-root-that-does-not-exist"); - const result = await readReferenceSource(missing, limits); + const result = await readReferenceSource(missing); expect(result.ok).toBe(false); if (result.ok) return; @@ -205,7 +183,7 @@ describe("readReferenceSource failures and exclusions", () => { it("returns typed failures for cancellation and invalid roots", async () => { const controller = new AbortController(); controller.abort(); - const cancelled = await readReferenceSource("/unused", limits, { + const cancelled = await readReferenceSource("/unused", { signal: controller.signal, }); expect(cancelled).toEqual({ @@ -217,10 +195,7 @@ describe("readReferenceSource failures and exclusions", () => { }, }); - const invalid = await readReferenceSource( - "/path/that/does/not/exist", - limits, - ); + const invalid = await readReferenceSource("/path/that/does/not/exist"); expect(invalid.ok).toBe(false); if (invalid.ok) return; expect(invalid.error.code).toBe("invalid-root"); diff --git a/tests/boundary/mcp/mainReload.test.ts b/tests/boundary/mcp/mainReload.test.ts index dfd462c1d..04dec3d17 100644 --- a/tests/boundary/mcp/mainReload.test.ts +++ b/tests/boundary/mcp/mainReload.test.ts @@ -6,7 +6,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; import type { ProjectPermissionStore } from "../../../src/application/ProjectPermissionStore.js"; import type { PermissionGrant } from "../../../src/domain/permissionPolicy.js"; import { ok } from "../../../src/domain/result.js"; @@ -54,30 +53,20 @@ describe("runtime permission reload", () => { await validReadStarted.promise; expect(runtime.options.processPolicy?.().status).toBe("disabled"); - expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ - fixture.oldRoot, - ]); expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false); expectOptionalPolicies(runtime.options, { status: "disabled" }); - expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(true); - expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false); releaseValidRead.resolve(); await expect - .poll(() => canRead(runtime.authority, fixture.newRoot)) - .toBe(true); + .poll(() => runtime.options.processPolicy?.().status) + .toBe("enabled"); expect(runtime.options.processPolicy?.().status).toBe("enabled"); - expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ - fixture.newRoot, - ]); expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(true); expectOptionalPolicies(runtime.options, { status: "enabled", root: fixture.newRoot, }); - expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(false); - expect(await canRead(runtime.authority, fixture.newRoot)).toBe(true); }); it("serializes overlapping reloads so an older read cannot commit last", async () => { @@ -109,17 +98,12 @@ describe("runtime permission reload", () => { releaseFirstRead.resolve(); await secondReadStarted.promise; await expect - .poll(() => canRead(runtime.authority, fixture.latestRoot)) - .toBe(true); + .poll(() => runtime.options.processPolicy?.().status) + .toBe("disabled"); expect(runtime.options.processPolicy?.().status).toBe("disabled"); - expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ - fixture.latestRoot, - ]); expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false); expectOptionalPolicies(runtime.options, { status: "disabled" }); - expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false); - expect(await canRead(runtime.authority, fixture.latestRoot)).toBe(true); }); it("continues with later reloads after an unexpected reload rejection", async () => { @@ -135,22 +119,14 @@ describe("runtime permission reload", () => { runtime.reload(); runtime.reload(); await expect.poll(() => reads).toBe(2); - await expect - .poll(() => canRead(runtime.authority, fixture.newRoot)) - .toBe(true); expect(reads).toBe(2); expect(runtime.options.processPolicy?.().status).toBe("enabled"); - expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ - fixture.newRoot, - ]); expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(true); expectOptionalPolicies(runtime.options, { status: "enabled", root: fixture.newRoot, }); - expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(false); - expect(await canRead(runtime.authority, fixture.newRoot)).toBe(true); }); }); @@ -199,7 +175,6 @@ const reloadFixture = async () => { ), ); const env: NodeJS.ProcessEnv = { - REA_EVIDENCE_ROOTS_JSON: JSON.stringify([oldRoot]), REA_PERMISSION_PROJECT_ROOT: projectRoot, REA_PERMISSION_PROJECT_STORE: join(directory, "permissions.json"), }; @@ -211,7 +186,6 @@ const configure = ( root: string, enabled: boolean, ): void => { - env.REA_EVIDENCE_ROOTS_JSON = JSON.stringify([root]); env.REA_PROCESS_CAPTURE_ENABLED = String(enabled); env.REA_PROCESS_EXECUTABLE_ROOTS_JSON = JSON.stringify([root]); env.REA_PROCESS_WORKING_ROOTS_JSON = JSON.stringify([root]); @@ -271,14 +245,12 @@ const startRuntime = async ( }, }); expect(result).toBe(0); - const authority = options?.permissionAuthority; - if (reload === undefined || options === undefined || authority === undefined) + if (reload === undefined || options === undefined) throw new Error("Runtime reload seam was not initialized"); const capturedOptions = options; return { reload, options: capturedOptions, - authority, }; }; @@ -294,7 +266,7 @@ const projectStore = ( const projectGrant = (id: string, root: string) => ({ grant_id: `project:${id}`, - capability: "evidence_read", + capability: "process_capture", roots: [root], executables: [], environment_names: [], @@ -305,25 +277,6 @@ const projectGrant = (id: string, root: string) => expires_at: null, }) satisfies PermissionGrant; -const canRead = async ( - authority: PermissionAuthority, - root: string, -): Promise => - ( - await authority.explain( - { - capability: "evidence_read", - roots: [root], - executables: [], - environment_names: [], - network: "none", - mount: false, - operation_identity: `read:${root}`, - }, - "read", - ) - ).ok; - const deferred = () => { let resolvePromise!: (value: T | PromiseLike) => void; const promise = new Promise((resolve) => {