fix: resolve validation and artifact edge cases

This commit is contained in:
N0zoM1z0 2026-07-14 16:15:24 +08:00
parent 6a3fd5b4ce
commit ef7f2f9374
9 changed files with 138 additions and 67 deletions

View File

@ -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 =

View File

@ -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<null, EvidenceFileError> => {
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 &&

View File

@ -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";

35
src/domain/jsonLimits.ts Normal file
View File

@ -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;
};

View File

@ -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),

View File

@ -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([

View File

@ -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");

36
tests/jsonLimits.test.ts Normal file
View File

@ -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);
});
});

View File

@ -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,