rea-agents 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -12
- package/dist/application/AnalysisSnapshotCache.js +8 -6
- package/dist/application/ArtifactGraphConstruction.js +11 -2
- package/dist/application/ArtifactInventory.js +3 -2
- package/dist/application/AuthorizedArtifactInventory.js +2 -1
- package/dist/application/BinarySession.js +12 -7
- package/dist/application/BoundedJsonFiles.js +6 -29
- package/dist/application/BrowserEvidence.js +35 -0
- package/dist/application/BrowserObservationPort.js +1 -0
- package/dist/application/BrowserObservationService.js +52 -0
- package/dist/application/CapabilityInventory.js +7 -0
- package/dist/application/ConfiguredRoots.js +19 -0
- package/dist/application/CrossVersionInvestigation.js +6 -0
- package/dist/application/DeferredFileAuthorization.js +61 -0
- package/dist/application/DirectAnalysis.js +38 -18
- package/dist/application/EvidenceLedger.js +6 -5
- package/dist/application/InvestigationWorkspaceStore.js +2 -26
- package/dist/application/ProcessCaptureAuthority.js +3 -2
- package/dist/application/ProcessCaptureLifecycle.js +18 -8
- package/dist/application/ProcessCheckpoints.js +1 -1
- package/dist/application/ProcessCli.js +17 -5
- package/dist/application/ProcessHarness.js +6 -19
- package/dist/application/ProcessNormalization.js +17 -2
- package/dist/application/ProcessOwnership.js +4 -3
- package/dist/application/ProcessSampling.js +52 -17
- package/dist/application/ProjectPermissionStore.js +106 -3
- package/dist/application/ReferenceSourceImportPolicy.js +3 -2
- package/dist/application/TerminalRenderer.js +1 -1
- package/dist/artifacts/MachOSliceArtifactReader.js +1 -1
- package/dist/artifacts/NativeDmgArtifactReader.js +1 -1
- package/dist/artifacts/SafeOutputTree.js +3 -3
- package/dist/browser/CdpBrowserProvider.js +158 -0
- package/dist/browser/CdpCaptureDocuments.js +140 -0
- package/dist/browser/CdpCaptureEvents.js +248 -0
- package/dist/browser/CdpCaptureStorage.js +53 -0
- package/dist/browser/CdpCaptureValues.js +70 -0
- package/dist/browser/CdpConnection.js +218 -0
- package/dist/browser/CdpEndpoint.js +114 -0
- package/dist/browser/CdpOptionalCommand.js +15 -0
- package/dist/browser/CdpPageCapture.js +273 -0
- package/dist/catalogIdentity.js +2 -0
- package/dist/cli.js +2 -0
- package/dist/cliBrowserCommands.js +136 -0
- package/dist/cliInvestigationCommands.js +19 -34
- package/dist/cliPolicyCommands.js +13 -10
- package/dist/config.js +44 -0
- package/dist/contracts/browserToolContracts.js +80 -0
- package/dist/contracts/errorSchemas.js +2 -0
- package/dist/contracts/toolContracts.js +3 -5
- package/dist/domain/browserObservation.js +361 -0
- package/dist/domain/changedBehavior.js +4 -15
- package/dist/domain/errors.js +41 -0
- package/dist/domain/jsonLimits.js +26 -0
- package/dist/domain/permissionPolicy.js +6 -2
- package/dist/domain/processCapture.js +3 -1
- package/dist/domain/processComparison.js +22 -34
- package/dist/domain/reconstructionVerification.js +2 -15
- package/dist/domain/reconstructionVerificationSchemas.js +2 -0
- package/dist/domain/staticRuntimeCorrelation.js +2 -0
- package/dist/domain/symbolAnalysis.js +1 -1
- package/dist/generatedPackageMetadata.js +2 -2
- package/dist/main.js +6 -0
- package/dist/native/CommandRunner.js +11 -4
- package/dist/server/createServer.js +7 -0
- package/dist/server/registerBrowserTools.js +36 -0
- package/dist/server/registerInvestigationTools.js +30 -35
- package/dist/server/registerProcessComparisonTool.js +2 -0
- package/dist/server/registerSessionTools.js +15 -2
- package/dist/server/runDerivedOperation.js +10 -2
- package/package.json +2 -1
- package/scripts/rea.mjs +1 -1
- package/skills/rea-analysis/SKILL.md +18 -3
|
@@ -85,7 +85,7 @@ export class EvidenceLedger {
|
|
|
85
85
|
}
|
|
86
86
|
/** Export records in deterministic, semantically irrelevant ID order. */
|
|
87
87
|
export() {
|
|
88
|
-
return createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]);
|
|
88
|
+
return structuredClone(createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]));
|
|
89
89
|
}
|
|
90
90
|
/** Check whether one immutable Evidence ID is present in this ledger. */
|
|
91
91
|
has(evidenceId) {
|
|
@@ -162,7 +162,7 @@ export class EvidenceLedger {
|
|
|
162
162
|
if (!checked.ok)
|
|
163
163
|
return checked;
|
|
164
164
|
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
|
165
|
-
return ok(unknown);
|
|
165
|
+
return ok(structuredClone(unknown));
|
|
166
166
|
}
|
|
167
167
|
/** Apply one approved full-state update using optimistic revision matching. */
|
|
168
168
|
updateUnknown(input, mutationEvidence) {
|
|
@@ -187,7 +187,8 @@ export class EvidenceLedger {
|
|
|
187
187
|
(filters.severity === undefined ||
|
|
188
188
|
unknown.severity === filters.severity) &&
|
|
189
189
|
(filters.domain === undefined || unknown.domain === filters.domain))
|
|
190
|
-
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
|
|
190
|
+
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
|
|
191
|
+
.map((unknown) => structuredClone(unknown));
|
|
191
192
|
}
|
|
192
193
|
/** Return current resolution validity; imported invalid states are rejected. */
|
|
193
194
|
verifyUnknownResolution(unknownId) {
|
|
@@ -197,7 +198,7 @@ export class EvidenceLedger {
|
|
|
197
198
|
return ok({
|
|
198
199
|
valid: unknown.status === "resolved",
|
|
199
200
|
truthVerified: unknown.resolution?.disposition === "verified",
|
|
200
|
-
unknown,
|
|
201
|
+
unknown: structuredClone(unknown),
|
|
201
202
|
});
|
|
202
203
|
}
|
|
203
204
|
/** Clear records when the owning session closes. */
|
|
@@ -225,7 +226,7 @@ export class EvidenceLedger {
|
|
|
225
226
|
if (!checked.ok)
|
|
226
227
|
return checked;
|
|
227
228
|
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
|
228
|
-
return ok(unknown);
|
|
229
|
+
return ok(structuredClone(unknown));
|
|
229
230
|
}
|
|
230
231
|
#validateCandidate(records, unknowns) {
|
|
231
232
|
if (records.size + unknowns.size > this.limits.maxRecords)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstat, open, readFile, realpath, unlink, } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
|
|
4
5
|
import { InvestigationWorkspaceError } from "../domain/errors.js";
|
|
5
6
|
import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
|
|
6
7
|
import { err, ok } from "../domain/result.js";
|
|
@@ -97,7 +98,7 @@ const readWorkspaceFile = async (destination, policy) => {
|
|
|
97
98
|
catch (cause) {
|
|
98
99
|
return err(new InvestigationWorkspaceError("read", "invalid-json", { cause }));
|
|
99
100
|
}
|
|
100
|
-
if (!
|
|
101
|
+
if (!isJsonWithinLimits(decoded, policy))
|
|
101
102
|
return err(new InvestigationWorkspaceError("read", "too-large"));
|
|
102
103
|
try {
|
|
103
104
|
return ok(parseInvestigationWorkspace(decoded));
|
|
@@ -182,31 +183,6 @@ const validateNextRevision = (current, next, expectedRevision) => {
|
|
|
182
183
|
? ok(null)
|
|
183
184
|
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
|
184
185
|
};
|
|
185
|
-
const withinJsonLimits = (root, policy) => {
|
|
186
|
-
const pending = [
|
|
187
|
-
{ value: root, depth: 0 },
|
|
188
|
-
];
|
|
189
|
-
let nodes = 0;
|
|
190
|
-
while (pending.length > 0) {
|
|
191
|
-
const current = pending.pop();
|
|
192
|
-
if (current === undefined)
|
|
193
|
-
break;
|
|
194
|
-
nodes += 1;
|
|
195
|
-
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
|
196
|
-
return false;
|
|
197
|
-
if (typeof current.value === "string" &&
|
|
198
|
-
current.value.length > policy.maxStringLength)
|
|
199
|
-
return false;
|
|
200
|
-
if (typeof current.value !== "object" || current.value === null)
|
|
201
|
-
continue;
|
|
202
|
-
for (const [key, value] of Object.entries(current.value)) {
|
|
203
|
-
if (key.length > policy.maxStringLength)
|
|
204
|
-
return false;
|
|
205
|
-
pending.push({ value, depth: current.depth + 1 });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return true;
|
|
209
|
-
};
|
|
210
186
|
const isFileNotFound = (cause) => errorCode(cause) === "ENOENT";
|
|
211
187
|
const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
|
|
212
188
|
const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { ProcessCaptureError, processCaptureCancelled, } from "./ProcessCaptureError.js";
|
|
3
|
+
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
|
3
4
|
const isWithin = (candidate, root) => candidate === root ||
|
|
4
5
|
candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
|
|
5
6
|
export const assertRealPathAuthority = async (scenario, policy) => {
|
|
6
7
|
const executable = await realpath(scenario.executable);
|
|
7
|
-
const executableRoots = await
|
|
8
|
+
const executableRoots = await canonicalizeConfiguredRoots(policy.executableRoots);
|
|
8
9
|
if (!executableRoots.some((root) => isWithin(executable, root)))
|
|
9
10
|
throw new ProcessCaptureError("resolved executable is outside approved roots");
|
|
10
11
|
const workingDirectory = await realpath(scenario.working_directory);
|
|
11
|
-
const workingRoots = await
|
|
12
|
+
const workingRoots = await canonicalizeConfiguredRoots(policy.workingRoots);
|
|
12
13
|
if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
|
|
13
14
|
throw new ProcessCaptureError("resolved working directory is outside approved roots");
|
|
14
15
|
for (const root of scenario.filesystem_roots) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { authorizeProcessScenario, digestProcessCommitment, processComparisonContract, processScenarioCommitment, } from "../domain/processCapture.js";
|
|
@@ -219,7 +219,6 @@ export const captureTerminalFrames = (options) => {
|
|
|
219
219
|
let outputBytes = 0;
|
|
220
220
|
let truncated = false;
|
|
221
221
|
options.terminal.onData((data) => {
|
|
222
|
-
options.onFirstOutput();
|
|
223
222
|
options.onOutput();
|
|
224
223
|
const bytes = Buffer.byteLength(data);
|
|
225
224
|
if (options.frames.length >= options.scenario.limits.frames ||
|
|
@@ -256,7 +255,7 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
|
|
|
256
255
|
throw new ProcessCaptureError("process capture produced no result");
|
|
257
256
|
return capture;
|
|
258
257
|
};
|
|
259
|
-
export const prepareProcessCapture = async (scenario, policy, signal) => {
|
|
258
|
+
export const prepareProcessCapture = async (scenario, policy, signal, host = systemProcessPreparationHost) => {
|
|
260
259
|
const decision = authorizeProcessScenario(scenario, policy);
|
|
261
260
|
if (!decision.allowed)
|
|
262
261
|
throw new ProcessCaptureError(decision.reason, {
|
|
@@ -266,11 +265,22 @@ export const prepareProcessCapture = async (scenario, policy, signal) => {
|
|
|
266
265
|
await assertRealPathAuthority(scenario, policy);
|
|
267
266
|
assertNotCancelled(signal);
|
|
268
267
|
const before = await snapshotRoots(scenario, signal);
|
|
269
|
-
const temporaryRoot = await
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
268
|
+
const temporaryRoot = await host.createTemporaryRoot();
|
|
269
|
+
try {
|
|
270
|
+
const runId = randomUUID();
|
|
271
|
+
const home = join(temporaryRoot, "home");
|
|
272
|
+
await host.createHome(home);
|
|
273
|
+
return { temporaryRoot, runId, home, before };
|
|
274
|
+
}
|
|
275
|
+
catch (cause) {
|
|
276
|
+
await host.cleanup(temporaryRoot);
|
|
277
|
+
throw cause;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const systemProcessPreparationHost = {
|
|
281
|
+
createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
|
|
282
|
+
createHome: (path) => mkdir(path),
|
|
283
|
+
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
|
274
284
|
};
|
|
275
285
|
const processPolicyMessage = (reason) => {
|
|
276
286
|
if (reason === "process capture is disabled")
|
|
@@ -105,7 +105,7 @@ export class ProcessCheckpoints {
|
|
|
105
105
|
effects: classifyFilesystemEffects(previous, after.files),
|
|
106
106
|
truncated: after.truncated,
|
|
107
107
|
});
|
|
108
|
-
return this.#captures;
|
|
108
|
+
return structuredClone(this.#captures);
|
|
109
109
|
}
|
|
110
110
|
/** Cancel scheduled captures and await any snapshot already in progress. */
|
|
111
111
|
async dispose() {
|
|
@@ -79,7 +79,13 @@ export const compareProcessEvidenceFiles = async (leftPath, rightPath) => {
|
|
|
79
79
|
}
|
|
80
80
|
};
|
|
81
81
|
const parseCaptureEvidence = (input) => {
|
|
82
|
-
|
|
82
|
+
let evidence;
|
|
83
|
+
try {
|
|
84
|
+
evidence = parseEvidence(input);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw invalidCaptureEvidence();
|
|
88
|
+
}
|
|
83
89
|
if (evidence.operation !== "capture_process_scenario" ||
|
|
84
90
|
evidence.predicate_type !== "rea.process-capture/v4" ||
|
|
85
91
|
evidence.provider.id !== PROCESS_PROVIDER.id ||
|
|
@@ -88,11 +94,17 @@ const parseCaptureEvidence = (input) => {
|
|
|
88
94
|
throw new ProcessCliFailure("invalid_input", LEGACY_PROCESS_CAPTURE_MESSAGE);
|
|
89
95
|
throw new ProcessCliFailure("invalid_input", "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.");
|
|
90
96
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
try {
|
|
98
|
+
return {
|
|
99
|
+
id: evidence.evidence_id,
|
|
100
|
+
capture: parseProcessCapture(evidence.normalized_result),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw invalidCaptureEvidence();
|
|
105
|
+
}
|
|
95
106
|
};
|
|
107
|
+
const invalidCaptureEvidence = () => new ProcessCliFailure("invalid_input", "Capture evidence is malformed. Create new capture evidence, then try again.");
|
|
96
108
|
const readJson = async (path) => {
|
|
97
109
|
let bytes;
|
|
98
110
|
try {
|
|
@@ -81,16 +81,6 @@ const scheduleScenarioEvents = (options) => {
|
|
|
81
81
|
timers.add(timer);
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
|
-
const startScenarioEvents = (options) => {
|
|
85
|
-
let eventsStarted = false;
|
|
86
|
-
return () => {
|
|
87
|
-
if (eventsStarted)
|
|
88
|
-
return;
|
|
89
|
-
eventsStarted = true;
|
|
90
|
-
scheduleScenarioEvents(options);
|
|
91
|
-
};
|
|
92
|
-
};
|
|
93
|
-
const eventsRequireReadiness = (scenario) => scenario.events.some((event) => event.type === "input" || event.type === "resize");
|
|
94
84
|
const cleanupFailedStartup = async (options) => {
|
|
95
85
|
options.terminal?.kill("SIGKILL");
|
|
96
86
|
const cleanupFailure = await releaseProcessResources({
|
|
@@ -134,23 +124,20 @@ const startCaptureRuntime = async (options) => {
|
|
|
134
124
|
rows: scenario.terminal.rows,
|
|
135
125
|
name: "xterm-256color",
|
|
136
126
|
});
|
|
137
|
-
const startEvents = startScenarioEvents({
|
|
138
|
-
...options,
|
|
139
|
-
getTerminal: () => terminal,
|
|
140
|
-
renderer,
|
|
141
|
-
started,
|
|
142
|
-
});
|
|
143
|
-
if (!eventsRequireReadiness(scenario))
|
|
144
|
-
startEvents();
|
|
145
127
|
const framesTruncated = captureTerminalFrames({
|
|
146
128
|
...options,
|
|
147
129
|
terminal,
|
|
148
130
|
started,
|
|
149
131
|
onOutput: () => (lastOutput = Date.now()),
|
|
150
|
-
onFirstOutput: startEvents,
|
|
151
132
|
renderer,
|
|
152
133
|
checkpoints,
|
|
153
134
|
});
|
|
135
|
+
scheduleScenarioEvents({
|
|
136
|
+
...options,
|
|
137
|
+
getTerminal: () => terminal,
|
|
138
|
+
renderer,
|
|
139
|
+
started,
|
|
140
|
+
});
|
|
154
141
|
const stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, options.samples);
|
|
155
142
|
return {
|
|
156
143
|
replay,
|
|
@@ -14,7 +14,7 @@ export const normalizeProcessText = (value, scenario, temporaryRoot, pid) => {
|
|
|
14
14
|
normalized = normalized.replaceAll(root, `<filesystem-root-${String(index)}>`);
|
|
15
15
|
}
|
|
16
16
|
if (scenario.normalization.pids)
|
|
17
|
-
normalized = normalized
|
|
17
|
+
normalized = normalizePidTokens(normalized, [pid]);
|
|
18
18
|
if (scenario.normalization.ports)
|
|
19
19
|
normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
|
|
20
20
|
for (const pattern of scenario.normalization.patterns)
|
|
@@ -35,6 +35,12 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
|
|
|
35
35
|
for (const identifier of identifiers)
|
|
36
36
|
if (identifier > 0 && !mapping.has(identifier))
|
|
37
37
|
mapping.set(identifier, mapping.size + 1);
|
|
38
|
+
const normalizeCommand = (command) => {
|
|
39
|
+
const normalized = normalizeProcessText(command, scenario, "<no-temporary-root>", rootPid);
|
|
40
|
+
return scenario.normalization.pids
|
|
41
|
+
? normalizePidTokens(normalized, identifiers)
|
|
42
|
+
: normalized;
|
|
43
|
+
};
|
|
38
44
|
return samples.map((sample) => ({
|
|
39
45
|
at_ms: Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
|
|
40
46
|
scenario.normalization.time_bucket_ms,
|
|
@@ -44,9 +50,18 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
|
|
|
44
50
|
? null
|
|
45
51
|
: (mapping.get(sample.process_group_id) ?? 0),
|
|
46
52
|
session_id: sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
|
|
47
|
-
command:
|
|
53
|
+
command: normalizeCommand(sample.command),
|
|
48
54
|
}));
|
|
49
55
|
};
|
|
56
|
+
const normalizePidTokens = (value, identifiers) => {
|
|
57
|
+
const tokens = [...new Set(identifiers)]
|
|
58
|
+
.filter((identifier) => Number.isSafeInteger(identifier) && identifier > 0)
|
|
59
|
+
.sort((left, right) => right - left)
|
|
60
|
+
.map(String);
|
|
61
|
+
if (tokens.length === 0)
|
|
62
|
+
return value;
|
|
63
|
+
return value.replace(new RegExp(`(?<!\\d)(?:${tokens.join("|")})(?!\\d)`, "gu"), "<pid>");
|
|
64
|
+
};
|
|
50
65
|
export const redactProtocolEvents = (events, scenario) => events.map((event) => ({
|
|
51
66
|
...event,
|
|
52
67
|
data: normalizeProcessText(event.data, scenario, "<no-temporary-root>", -1),
|
|
@@ -2,9 +2,10 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
const execFileAsync = promisify(execFile);
|
|
5
|
-
|
|
5
|
+
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
|
6
|
+
export const parseProcessEnvironment = (value) => Object.fromEntries(value
|
|
6
7
|
.split("\0")
|
|
7
|
-
.filter((entry) => entry.
|
|
8
|
+
.filter((entry) => entry.indexOf("=") > 0)
|
|
8
9
|
.map((entry) => {
|
|
9
10
|
const separator = entry.indexOf("=");
|
|
10
11
|
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
|
@@ -31,7 +32,7 @@ const systemHost = {
|
|
|
31
32
|
},
|
|
32
33
|
async environment(pid) {
|
|
33
34
|
if (process.platform === "linux") {
|
|
34
|
-
return
|
|
35
|
+
return parseProcessEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
|
|
35
36
|
}
|
|
36
37
|
const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
|
|
37
38
|
const environment = {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
const execFileAsync = promisify(execFile);
|
|
5
5
|
const PROC_READ_CONCURRENCY = 64;
|
|
@@ -67,23 +67,58 @@ const readProcessCommand = async (pid, signal) => {
|
|
|
67
67
|
return handleProcReadFailure(cause);
|
|
68
68
|
}
|
|
69
69
|
};
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
signal
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
70
|
+
const systemLinuxChildrenHost = {
|
|
71
|
+
async taskIds(pid, signal) {
|
|
72
|
+
try {
|
|
73
|
+
signal.throwIfAborted();
|
|
74
|
+
const entries = await readdir(`/proc/${String(pid)}/task`);
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
return entries
|
|
77
|
+
.filter((entry) => /^\d+$/.test(entry))
|
|
78
|
+
.map(Number)
|
|
79
|
+
.filter((taskId) => Number.isSafeInteger(taskId) && taskId > 0)
|
|
80
|
+
.sort((left, right) => left - right);
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
return handleProcReadFailure(cause);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
async children(pid, taskId, signal) {
|
|
87
|
+
try {
|
|
88
|
+
return await readFile(`/proc/${String(pid)}/task/${String(taskId)}/children`, { encoding: "utf8", signal });
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
return handleProcReadFailure(cause);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
/** Read the deduplicated children created by every thread in a Linux process. */
|
|
96
|
+
export const readLinuxChildren = async (pid, signal, host = systemLinuxChildrenHost) => {
|
|
97
|
+
const taskIds = await host.taskIds(pid, signal);
|
|
98
|
+
if (taskIds === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
const children = new Set();
|
|
101
|
+
let successfulReads = 0;
|
|
102
|
+
for (let offset = 0; offset < taskIds.length; offset += PROC_READ_CONCURRENCY) {
|
|
103
|
+
const texts = await Promise.all(taskIds
|
|
104
|
+
.slice(offset, offset + PROC_READ_CONCURRENCY)
|
|
105
|
+
.map((taskId) => host.children(pid, taskId, signal)));
|
|
106
|
+
for (const text of texts) {
|
|
107
|
+
if (text === undefined)
|
|
108
|
+
continue;
|
|
109
|
+
successfulReads += 1;
|
|
110
|
+
for (const value of text.trim().split(/\s+/)) {
|
|
111
|
+
if (!/^\d+$/.test(value))
|
|
112
|
+
continue;
|
|
113
|
+
const child = Number(value);
|
|
114
|
+
if (Number.isSafeInteger(child) && child > 0)
|
|
115
|
+
children.add(child);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
80
118
|
}
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
.filter((value) => /^\d+$/.test(value))
|
|
85
|
-
.map(Number)
|
|
86
|
-
.filter((child) => Number.isSafeInteger(child) && child > 0);
|
|
119
|
+
return successfulReads === 0
|
|
120
|
+
? undefined
|
|
121
|
+
: [...children].sort((left, right) => left - right);
|
|
87
122
|
};
|
|
88
123
|
const inspectProcess = async (pid, expectedParent, signal, identities) => {
|
|
89
124
|
const before = await readProcessStat(pid, signal);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, stat, unlink, } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
5
|
import writeFileAtomic from "write-file-atomic";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { err, ok } from "../domain/result.js";
|
|
@@ -8,6 +9,7 @@ const grantSchema = z.object({
|
|
|
8
9
|
grant_id: z.string().min(1),
|
|
9
10
|
capability: z.enum([
|
|
10
11
|
"process_capture",
|
|
12
|
+
"browser_observe",
|
|
11
13
|
"evidence_read",
|
|
12
14
|
"evidence_write",
|
|
13
15
|
"investigation_input",
|
|
@@ -22,6 +24,7 @@ const grantSchema = z.object({
|
|
|
22
24
|
roots: z.array(z.string()),
|
|
23
25
|
executables: z.array(z.string()),
|
|
24
26
|
environment_names: z.array(z.string()),
|
|
27
|
+
origins: z.array(z.string()).optional(),
|
|
25
28
|
network: z.enum(["none", "loopback", "external"]),
|
|
26
29
|
mount: z.boolean(),
|
|
27
30
|
lifetime: z.literal("project"),
|
|
@@ -65,7 +68,15 @@ export const readProjectPermissionStore = async (path, projectRoot) => {
|
|
|
65
68
|
const metadata = await stat(path);
|
|
66
69
|
if ((metadata.mode & 0o077) !== 0)
|
|
67
70
|
return err(new ProjectPermissionStoreError("not_owner_only"));
|
|
68
|
-
const
|
|
71
|
+
const encoded = await readFile(path, "utf8");
|
|
72
|
+
let decoded;
|
|
73
|
+
try {
|
|
74
|
+
decoded = JSON.parse(encoded);
|
|
75
|
+
}
|
|
76
|
+
catch (cause) {
|
|
77
|
+
return err(new ProjectPermissionStoreError("invalid", { cause }));
|
|
78
|
+
}
|
|
79
|
+
const parsed = storeSchema.safeParse(decoded);
|
|
69
80
|
if (!parsed.success ||
|
|
70
81
|
parsed.data.project_id !== project.value.id ||
|
|
71
82
|
parsed.data.project_root !== project.value.root)
|
|
@@ -107,4 +118,96 @@ export const writeProjectPermissionStore = async (path, projectRoot, grants) =>
|
|
|
107
118
|
return err(new ProjectPermissionStoreError("io", { cause }));
|
|
108
119
|
}
|
|
109
120
|
};
|
|
110
|
-
|
|
121
|
+
/** Revoke one grant while serializing the complete read-modify-write cycle. */
|
|
122
|
+
export const revokeProjectPermissionGrant = async (path, projectRoot, grantId) => {
|
|
123
|
+
let lock;
|
|
124
|
+
try {
|
|
125
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
126
|
+
const acquired = await acquirePermissionStoreLock(path);
|
|
127
|
+
if (!acquired.ok)
|
|
128
|
+
return acquired;
|
|
129
|
+
lock = acquired.value;
|
|
130
|
+
const current = await readProjectPermissionStore(path, projectRoot);
|
|
131
|
+
if (!current.ok)
|
|
132
|
+
return current;
|
|
133
|
+
const grants = current.value?.grants ?? [];
|
|
134
|
+
const retained = grants.filter(({ grant_id }) => grant_id !== grantId);
|
|
135
|
+
if (retained.length === grants.length)
|
|
136
|
+
return ok(false);
|
|
137
|
+
const written = await writeProjectPermissionStore(path, projectRoot, retained);
|
|
138
|
+
return written.ok ? ok(true) : written;
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
return err(new ProjectPermissionStoreError("io", { cause }));
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
if (lock !== undefined)
|
|
145
|
+
await releasePermissionStoreLock(lock);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const acquirePermissionStoreLock = async (destination) => {
|
|
149
|
+
const path = `${destination}.lock`;
|
|
150
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
151
|
+
try {
|
|
152
|
+
return ok(await createPermissionStoreLock(path));
|
|
153
|
+
}
|
|
154
|
+
catch (cause) {
|
|
155
|
+
if (!isAlreadyExists(cause))
|
|
156
|
+
return err(new ProjectPermissionStoreError("io", { cause }));
|
|
157
|
+
if (await removeStalePermissionStoreLock(path))
|
|
158
|
+
continue;
|
|
159
|
+
if (attempt === 99)
|
|
160
|
+
return err(new ProjectPermissionStoreError("locked", { cause }));
|
|
161
|
+
await delay(10);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return err(new ProjectPermissionStoreError("locked"));
|
|
165
|
+
};
|
|
166
|
+
const createPermissionStoreLock = async (path) => {
|
|
167
|
+
const handle = await open(path, "wx", 0o600);
|
|
168
|
+
try {
|
|
169
|
+
await handle.writeFile(`${String(process.pid)}\n`, "utf8");
|
|
170
|
+
await handle.sync();
|
|
171
|
+
return { path, handle };
|
|
172
|
+
}
|
|
173
|
+
catch (cause) {
|
|
174
|
+
await handle.close().catch(() => undefined);
|
|
175
|
+
await unlink(path).catch(() => undefined);
|
|
176
|
+
throw cause;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const removeStalePermissionStoreLock = async (path) => {
|
|
180
|
+
try {
|
|
181
|
+
const metadata = await lstat(path);
|
|
182
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 32)
|
|
183
|
+
return false;
|
|
184
|
+
const encoded = (await readFile(path, "utf8")).trim();
|
|
185
|
+
if (!/^[1-9][0-9]{0,9}$/u.test(encoded))
|
|
186
|
+
return false;
|
|
187
|
+
if (processIsAlive(Number(encoded)))
|
|
188
|
+
return false;
|
|
189
|
+
await unlink(path);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const processIsAlive = (pid) => {
|
|
197
|
+
try {
|
|
198
|
+
process.kill(pid, 0);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
catch (cause) {
|
|
202
|
+
return errorCode(cause) !== "ESRCH";
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const releasePermissionStoreLock = async (lock) => {
|
|
206
|
+
await lock.handle.close().catch(() => undefined);
|
|
207
|
+
await unlink(lock.path).catch(() => undefined);
|
|
208
|
+
};
|
|
209
|
+
const isNotFound = (cause) => errorCode(cause) === "ENOENT";
|
|
210
|
+
const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
|
|
211
|
+
const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
|
|
212
|
+
? cause.code
|
|
213
|
+
: undefined;
|
|
@@ -4,6 +4,7 @@ import ignore from "ignore";
|
|
|
4
4
|
import { err, ok } from "../domain/result.js";
|
|
5
5
|
import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
|
|
6
6
|
import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS, DEFAULT_REFERENCE_SOURCE_LIMITS, } from "./ReferenceSourceImportTypes.js";
|
|
7
|
+
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
|
7
8
|
const failure = (code, message) => ({
|
|
8
9
|
tag: "reference-source-import",
|
|
9
10
|
code,
|
|
@@ -29,8 +30,8 @@ const authorizeRoot = async (requestedRoot, approvedRoots) => {
|
|
|
29
30
|
if (!(await stat(requestedRoot)).isDirectory())
|
|
30
31
|
return err(failure("invalid-root", "Reference source root is not a directory"));
|
|
31
32
|
const canonicalRoot = await realpath(resolve(requestedRoot));
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const canonicalApprovedRoots = await canonicalizeConfiguredRoots(approvedRoots.map((approvedRoot) => resolve(approvedRoot)));
|
|
34
|
+
for (const canonicalApproved of canonicalApprovedRoots) {
|
|
34
35
|
if (withinRoot(canonicalApproved, canonicalRoot))
|
|
35
36
|
return ok(canonicalRoot);
|
|
36
37
|
}
|
|
@@ -49,7 +49,7 @@ export class TerminalRenderer {
|
|
|
49
49
|
/** Await all queued parsing and return immutable rendered observations. */
|
|
50
50
|
async frames() {
|
|
51
51
|
await this.#pending;
|
|
52
|
-
return this.#frames;
|
|
52
|
+
return structuredClone(this.#frames);
|
|
53
53
|
}
|
|
54
54
|
/** Whether a rendered observation exceeded its independent capture budget. */
|
|
55
55
|
truncated() {
|
|
@@ -59,7 +59,7 @@ export class MachOSliceArtifactReader {
|
|
|
59
59
|
return Promise.resolve(createReadStream(this.path, { start: offset, end: offset + size - 1 }));
|
|
60
60
|
}
|
|
61
61
|
provenance() {
|
|
62
|
-
return this.#command === undefined ? [] : [this.#command];
|
|
62
|
+
return this.#command === undefined ? [] : [structuredClone(this.#command)];
|
|
63
63
|
}
|
|
64
64
|
close() {
|
|
65
65
|
return Promise.resolve();
|
|
@@ -59,7 +59,7 @@ export class SafeOutputTree {
|
|
|
59
59
|
return this.#outputRoot;
|
|
60
60
|
}
|
|
61
61
|
get cleanup() {
|
|
62
|
-
return this.#cleanup;
|
|
62
|
+
return structuredClone(this.#cleanup);
|
|
63
63
|
}
|
|
64
64
|
/** Stream one selected regular file with exact byte and digest verification. */
|
|
65
65
|
async write(relativePath, source, expectedSha256, signal) {
|
|
@@ -123,7 +123,7 @@ export class SafeOutputTree {
|
|
|
123
123
|
/** Remove only this operation's unsealed tree and verify absence. */
|
|
124
124
|
async rollback() {
|
|
125
125
|
if (this.#published)
|
|
126
|
-
return this.#cleanup;
|
|
126
|
+
return structuredClone(this.#cleanup);
|
|
127
127
|
await rm(this.#stagingRoot, { recursive: true, force: true });
|
|
128
128
|
const absent = await isAbsent(this.#stagingRoot);
|
|
129
129
|
this.#cleanup = {
|
|
@@ -133,7 +133,7 @@ export class SafeOutputTree {
|
|
|
133
133
|
};
|
|
134
134
|
if (!absent)
|
|
135
135
|
throw new ArtifactReaderFailure("integrity", "Extraction output cleanup could not be verified");
|
|
136
|
-
return this.#cleanup;
|
|
136
|
+
return structuredClone(this.#cleanup);
|
|
137
137
|
}
|
|
138
138
|
async #prepareParent(relativePath) {
|
|
139
139
|
const parts = relativePath.split("/");
|