diff --git a/src/application/ArtifactGraphConstruction.ts b/src/application/ArtifactGraphConstruction.ts index 01aa6a80d..fd7afe0dc 100644 --- a/src/application/ArtifactGraphConstruction.ts +++ b/src/application/ArtifactGraphConstruction.ts @@ -215,7 +215,7 @@ export const createArtifactEdges = ( occurrence.artifact_id === null ) continue; - const mappedSource = occurrence.logical_path.endsWith(".map") + const mappedSource = occurrence.logical_path.toLowerCase().endsWith(".map") ? byPath.get(occurrence.logical_path.slice(0, -".map".length)) : undefined; const parentArtifactId = diff --git a/src/application/BoundedJsonFiles.ts b/src/application/BoundedJsonFiles.ts index 21e936460..b9a95d034 100644 --- a/src/application/BoundedJsonFiles.ts +++ b/src/application/BoundedJsonFiles.ts @@ -5,6 +5,7 @@ import writeFileAtomic from "write-file-atomic"; 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 { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js"; @@ -32,8 +33,9 @@ export const readBoundedJson = async ( } catch (cause: unknown) { return err(new EvidenceFileError("read", "invalid-json", { cause })); } - const limits = validateJsonLimits(decoded, policy); - return limits.ok ? ok(decoded) : limits; + return isJsonWithinLimits(decoded, policy) + ? ok(decoded) + : err(new EvidenceFileError("read", "too-large")); } catch (cause: unknown) { return err(new EvidenceFileError("read", "io", { cause })); } @@ -96,35 +98,6 @@ const isApproved = async ( return false; }; -const validateJsonLimits = ( - root: unknown, - policy: EvidenceFilePolicy, -): Result => { - const pending: Array<{ readonly value: unknown; readonly depth: number }> = [ - { value: root, depth: 0 }, - ]; - let nodes = 0; - while (pending.length > 0) { - const current = pending.pop(); - if (current === undefined) break; - nodes += 1; - if (nodes > policy.maxNodes || current.depth > policy.maxDepth) - return err(new EvidenceFileError("read", "too-large")); - if ( - typeof current.value === "string" && - current.value.length > policy.maxStringLength - ) - return err(new EvidenceFileError("read", "too-large")); - if (typeof current.value !== "object" || current.value === null) continue; - for (const [key, value] of Object.entries(current.value)) { - if (key.length > policy.maxStringLength) - return err(new EvidenceFileError("read", "too-large")); - pending.push({ value, depth: current.depth + 1 }); - } - } - return ok(null); -}; - const isFileNotFound = (cause: unknown): boolean => typeof cause === "object" && cause !== null && diff --git a/src/application/InvestigationWorkspaceStore.ts b/src/application/InvestigationWorkspaceStore.ts index 64a06f226..d77ba37ca 100644 --- a/src/application/InvestigationWorkspaceStore.ts +++ b/src/application/InvestigationWorkspaceStore.ts @@ -11,6 +11,7 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; import writeFileAtomic from "write-file-atomic"; import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; +import { isJsonWithinLimits } from "../domain/jsonLimits.js"; import { InvestigationWorkspaceError } from "../domain/errors.js"; import { parseInvestigationWorkspace, @@ -132,7 +133,7 @@ const readWorkspaceFile = async ( new InvestigationWorkspaceError("read", "invalid-json", { cause }), ); } - if (!withinJsonLimits(decoded, policy)) + if (!isJsonWithinLimits(decoded, policy)) return err(new InvestigationWorkspaceError("read", "too-large")); try { return ok(parseInvestigationWorkspace(decoded)); @@ -241,34 +242,6 @@ const validateNextRevision = ( : err(new InvestigationWorkspaceError("update", "revision-conflict")); }; -const withinJsonLimits = ( - root: unknown, - policy: EvidenceFilePolicy, -): boolean => { - const pending: Array<{ readonly value: unknown; readonly depth: number }> = [ - { value: root, depth: 0 }, - ]; - let nodes = 0; - while (pending.length > 0) { - const current = pending.pop(); - if (current === undefined) break; - nodes += 1; - if (nodes > policy.maxNodes || current.depth > policy.maxDepth) - return false; - if ( - typeof current.value === "string" && - current.value.length > policy.maxStringLength - ) - return false; - if (typeof current.value !== "object" || current.value === null) continue; - for (const [key, value] of Object.entries(current.value)) { - if (key.length > policy.maxStringLength) return false; - pending.push({ value, depth: current.depth + 1 }); - } - } - return true; -}; - const isFileNotFound = (cause: unknown): boolean => errorCode(cause) === "ENOENT"; diff --git a/src/domain/jsonLimits.ts b/src/domain/jsonLimits.ts new file mode 100644 index 000000000..2ffa0d8b4 --- /dev/null +++ b/src/domain/jsonLimits.ts @@ -0,0 +1,35 @@ +/** Structural limits applied while traversing an unknown JSON value. */ +export interface JsonLimits { + readonly maxDepth: number; + readonly maxStringLength: number; + readonly maxNodes: number; +} + +/** Return whether every node, key, and string fits within the supplied limits. */ +export const isJsonWithinLimits = ( + root: unknown, + limits: JsonLimits, +): boolean => { + const pending: Array<{ readonly value: unknown; readonly depth: number }> = [ + { value: root, depth: 0 }, + ]; + let nodes = 0; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + nodes += 1; + if (nodes > limits.maxNodes || current.depth > limits.maxDepth) + return false; + if ( + typeof current.value === "string" && + current.value.length > limits.maxStringLength + ) + return false; + if (typeof current.value !== "object" || current.value === null) continue; + for (const [key, value] of Object.entries(current.value)) { + if (key.length > limits.maxStringLength) return false; + pending.push({ value, depth: current.depth + 1 }); + } + } + return true; +}; diff --git a/src/domain/permissionPolicy.ts b/src/domain/permissionPolicy.ts index 4e62aa666..917dff5e9 100644 --- a/src/domain/permissionPolicy.ts +++ b/src/domain/permissionPolicy.ts @@ -165,10 +165,7 @@ export const evaluatePermission = ( reason: coveringGrant !== undefined && isExpired(coveringGrant, now) ? "grant_expired" - : candidates.length === 0 && - policy.grants.some( - (grant) => grant.capability === normalized.capability, - ) + : coveringGrant !== undefined ? "grant_revoked_or_consumed" : "grant_required", missing: missingFromScopes(normalized, candidates), diff --git a/tests/artifactProvider.test.ts b/tests/artifactProvider.test.ts index e05f9ffe0..b83495aa4 100644 --- a/tests/artifactProvider.test.ts +++ b/tests/artifactProvider.test.ts @@ -53,7 +53,7 @@ describe("artifact graph provider", () => { machO, ); await writeFile(join(app, "Contents", "Resources", "main.js"), "main();\n"); - await writeFile(join(app, "Contents", "Resources", "main.js.map"), "{}\n"); + await writeFile(join(app, "Contents", "Resources", "main.js.MAP"), "{}\n"); await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside")); const client = new ArtifactProvider().createClient( target(app, "directory"), @@ -88,7 +88,7 @@ describe("artifact graph provider", () => { path.endsWith("main.js"), ); const sourceMap = left.occurrences.items.find(({ logical_path: path }) => - path.endsWith("main.js.map"), + path.endsWith("main.js.MAP"), ); expect(left.edges.items).toEqual( expect.arrayContaining([ diff --git a/tests/investigationWorkspace.test.ts b/tests/investigationWorkspace.test.ts index ae0992069..e064cd585 100644 --- a/tests/investigationWorkspace.test.ts +++ b/tests/investigationWorkspace.test.ts @@ -239,6 +239,27 @@ describe("persistent cross-version investigation workspace", () => { }); }); + it("enforces shared JSON depth limits while reading workspaces", async () => { + const { path, input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const completed = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); + if (!completed.ok) throw completed.error; + + await expect( + readInvestigationWorkspace(path, { + ...policy(directory), + maxDepth: 1, + }), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "InvestigationWorkspaceError", reason: "too-large" }, + }); + }); + it("resumes from a comparison checkpoint without recomputing its identity", async () => { const { input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root"); diff --git a/tests/jsonLimits.test.ts b/tests/jsonLimits.test.ts new file mode 100644 index 000000000..603e92763 --- /dev/null +++ b/tests/jsonLimits.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { isJsonWithinLimits } from "../src/domain/jsonLimits.js"; + +const limits = { + maxDepth: 3, + maxStringLength: 8, + maxNodes: 6, +}; + +describe("JSON limits", () => { + it("traverses nested object and array values", () => { + expect(isJsonWithinLimits({ one: ["two", { three: 3 }] }, limits)).toBe( + true, + ); + expect( + isJsonWithinLimits({ one: ["two", { three: "too-long-value" }] }, limits), + ).toBe(false); + }); + + it("enforces depth, node, and object-key limits", () => { + expect( + isJsonWithinLimits( + { one: { two: { three: null } } }, + { + ...limits, + maxDepth: 2, + }, + ), + ).toBe(false); + expect(isJsonWithinLimits([1, 2, 3], { ...limits, maxNodes: 3 })).toBe( + false, + ); + expect(isJsonWithinLimits({ "too-long-key": true }, limits)).toBe(false); + }); +}); diff --git a/tests/permissionPolicy.test.ts b/tests/permissionPolicy.test.ts index c1ffc1e34..50c09888b 100644 --- a/tests/permissionPolicy.test.ts +++ b/tests/permissionPolicy.test.ts @@ -176,6 +176,42 @@ describe("permission policy", () => { }); }); + it("reports a missing scope when revoked grants cover a different root", () => { + const ceiling = { + capability: "evidence_write" as const, + roots: ["/workspace"], + executables: [], + environment_names: [], + network: "none" as const, + mount: false, + }; + const granted = grantPermission(createPermissionPolicy([ceiling]), { + ...ceiling, + roots: ["/workspace/first"], + grant_id: "revoked-other-root", + lifetime: "project", + operation_identity: null, + expires_at: null, + }); + expect(granted.ok).toBe(true); + if (!granted.ok) return; + + expect( + evaluatePermission( + revokePermission(granted.value, "revoked-other-root"), + { + ...ceiling, + roots: ["/workspace/second"], + operation_identity: "write:report.json", + }, + ), + ).toMatchObject({ + allowed: false, + reason: "grant_required", + missing: { roots: ["/workspace/second"] }, + }); + }); + it("expires grants, removes session authority on disconnect, and never combines partial grants", () => { const ceiling = { capability: "process_capture" as const,