diff --git a/src/application/environment/environmentBuild.test.ts b/src/application/environment/environmentBuild.test.ts index aac3f1f493..65b995fbfa 100644 --- a/src/application/environment/environmentBuild.test.ts +++ b/src/application/environment/environmentBuild.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join, sep } from "node:path"; import { describe, expect, it } from "vitest"; @@ -318,6 +318,129 @@ describe("buildEnvironment naming and refusals", () => { }); }); +describe("buildEnvironment cancellation and input checks", () => { + it("writes nothing when cancelled before the bundle stage", async () => { + const root = await createTestTempDirectory("rea-environment-build-stop-"); + const controller = new AbortController(); + const input = await buildInput(root, undefined, { + signal: controller.signal, + progress: (stage) => { + if (stage === "state") controller.abort(); + }, + }); + const built = await buildEnvironment(input); + if (built.ok) throw new Error("Expected a cancellation"); + expect(projectAnalysisError(built.error)).toMatchObject({ + code: "cancelled", + details: { operation: "environment-build" }, + }); + await expect(readdir(input.directory!)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("names the written bundle when cancelled during the write", async () => { + const root = await createTestTempDirectory("rea-environment-build-late-"); + const controller = new AbortController(); + const input = await buildInput(root, undefined, { + signal: controller.signal, + progress: (stage) => { + if (stage === "bundle") controller.abort(); + }, + }); + const built = await buildEnvironment(input); + if (built.ok) throw new Error("Expected a cancellation"); + const projected = projectAnalysisError(built.error); + expect(projected.code).toBe("cancelled"); + expect(JSON.stringify(projected)).toContain( + `"output_directory":${JSON.stringify(input.directory)}`, + ); + expect(await listFiles(input.directory!)).toHaveLength(7); + }); + + it("refuses repeated rules and a HAR document saved as JSON", async () => { + const root = await createTestTempDirectory("rea-environment-build-input-"); + const repeated = await buildEnvironment( + await buildInput(root, undefined, { + templateRules: ["opaque-token", "opaque-token"], + }), + ); + if (repeated.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(repeated.error)).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["template_rules"] }] }, + }); + const harPath = join(root, "grafana.json"); + await writeFile(harPath, JSON.stringify({ log: { entries: [] } })); + const har = await readEnvironmentExportFile("evidence", harPath); + if (!har.ok) throw har.error; + const asJson = await buildEnvironment({ + capture: har.value, + captureInput: "evidence", + capturePath: harPath, + templateRules: DEFAULT_BUILD_TEMPLATE_RULES, + dryRun: true, + }); + if (asJson.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(asJson.error)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining(".har"), + details: { issues: [{ path: ["capture"] }] }, + }); + }); + + it("refuses an unreadable package tarball on a dry run as a build does", async () => { + if (process.getuid?.() === 0) return; + const root = await createTestTempDirectory("rea-environment-build-tgz-"); + const tarball = join(root, "rea-agents.tgz"); + await writeFile(tarball, "packed"); + await chmod(tarball, 0o000); + try { + const planned = await buildEnvironment( + await buildInput(root, undefined, { + dryRun: true, + packageTarball: tarball, + }), + ); + const built = await buildEnvironment( + await buildInput(root, undefined, { packageTarball: tarball }), + ); + if (planned.ok || built.ok) throw new Error("Expected both refusals"); + expect(projectAnalysisError(planned.error).code).toBe("access_denied"); + expect(projectAnalysisError(built.error).code).toBe("access_denied"); + } finally { + await chmod(tarball, 0o600); + } + }); + + it("refuses a loopback capture whose file name is too long, and quotes the next command", async () => { + const root = await createTestTempDirectory("rea-environment-build-long-"); + const long = await captureFile( + root, + loopbackCapture(), + `${"a".repeat(70)}.capture.json`, + ); + const refused = await buildEnvironment({ + capture: long, + captureInput: "evidence", + capturePath: long.path, + templateRules: DEFAULT_BUILD_TEMPLATE_RULES, + dryRun: true, + }); + if (refused.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(refused.error)).toMatchObject({ + details: { operation: "environment-build", issues: [{ path: ["name"] }] }, + }); + const built = await buildEnvironment( + await buildInput(root, undefined, { directory: join(root, "my bundle") }), + ); + if (!built.ok) throw built.error; + expect(built.value.next).toEqual([ + `agent-env run '${join(root, "my bundle")}'`, + ]); + }); +}); + describe("classifyBuildCapture", () => { it("inspects .har files, reads .json Evidence and refuses anything else", () => { expect(classifyBuildCapture("/x/grafana.HAR")).toEqual({ diff --git a/src/application/environment/environmentBuild.ts b/src/application/environment/environmentBuild.ts index 1a3dfff6fc..b2d9c877e9 100644 --- a/src/application/environment/environmentBuild.ts +++ b/src/application/environment/environmentBuild.ts @@ -1,7 +1,11 @@ -import { stat } from "node:fs/promises"; +import { open } from "node:fs/promises"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisAccessDeniedError, + AnalysisCancelledError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; import { summarizeEnvironmentCoverage, type EnvironmentCoverage, @@ -20,6 +24,7 @@ import { err, ok, type Result } from "../../domain/result.js"; import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; import { ENVIRONMENT_EXPORT_OPERATION, + isPortableBundleName, planEnvironmentBundle, registryPackageSource, type EnvironmentPackageSource, @@ -110,6 +115,8 @@ export interface EnvironmentBuildInput { readonly dryRun: boolean; /** Called as each stage starts. */ readonly progress?: ((stage: string) => void) | undefined; + /** Checked between stages; a build cancelled before the write writes nothing. */ + readonly signal?: AbortSignal | undefined; } export interface EnvironmentBuildReport { @@ -143,6 +150,59 @@ const captureOperations = new Set([ "capture_browser_scenario", ]); +const harDocument = (value: unknown): boolean => { + if (typeof value !== "object" || value === null) return false; + const log: unknown = Reflect.get(value, "log"); + return ( + typeof log === "object" && + log !== null && + Array.isArray(Reflect.get(log, "entries")) + ); +}; + +const harAsJson = (path: string): AnalysisInputError => + new AnalysisInputError( + ENVIRONMENT_BUILD_OPERATION, + { + userMessage: `${path} is a HAR document; name it with .har to inspect it.`, + }, + [ + { + path: ["capture"], + reason: "invalid_value", + expected: "capture Evidence JSON, or a .har file", + message: + "A .json capture must be capture Evidence; a HAR document is inspected only from a .har file.", + }, + ], + ); + +const repeatedRules = (): AnalysisInputError => + new AnalysisInputError(ENVIRONMENT_BUILD_OPERATION, undefined, [ + { + path: ["template_rules"], + reason: "invalid_value", + message: "template_rules must not repeat a rule", + }, + ]); + +/** A cancellation, naming the bundle when it arrived after the bundle was written. */ +const cancelled = ( + signal: AbortSignal | undefined, + written?: string, +): AnalysisCancelledError | undefined => + signal?.aborted === true + ? new AnalysisCancelledError(ENVIRONMENT_BUILD_OPERATION, { + cause: signal.reason, + ...(written === undefined + ? {} + : { + userMessage: `The build was cancelled after it wrote the bundle at ${written}.`, + diagnostics: { output_directory: written, bundle_written: true }, + }), + }) + : undefined; + const notCaptureEvidence = (operation: string): AnalysisInputError => new AnalysisInputError( ENVIRONMENT_BUILD_OPERATION, @@ -164,7 +224,7 @@ const unnamed = (path: string): AnalysisInputError => new AnalysisInputError( ENVIRONMENT_BUILD_OPERATION, { - userMessage: `The capture's origin is a loopback or IP address and ${path} yields no name; pass --name.`, + userMessage: `The capture's origin is a loopback or IP address, and ${path} yields no portable environment name (a kebab-case label of at most 64 characters that is not a Windows device name); pass --name.`, }, [ { @@ -198,7 +258,7 @@ const environmentName = ( if (input.name !== undefined) return ok(input.name); if (!anonymousOrigin(manifest.origin)) return ok(undefined); const name = nameFromCaptureFile(input.capturePath); - if (name === "") return err(unnamed(input.capturePath)); + if (!isPortableBundleName(name)) return err(unnamed(input.capturePath)); warnings.push({ code: "name-from-file", message: `The capture's origin ${manifest.origin} names no application, so the environment is named ${name} after the capture file; pass --name to choose another.`, @@ -206,19 +266,41 @@ const environmentName = ( return ok(name); }; +/** A path as one POSIX shell word. */ +const shellWord = (path: string): string => + /^[\w./@%+=:,-]+$/u.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`; + const packageSourceValue = (source: EnvironmentPackageSource): JsonValue => source.kind === "registry" ? { kind: "registry", specifier: source.specifier } : { kind: "tarball", path: source.path }; +/** The tarball's size once it opens for reading as a regular file, refused as a build would refuse it. */ const tarballBytes = async ( path: string, -): Promise> => { +): Promise> => { try { - const found = await stat(path); - if (found.isFile()) return ok(found.size); - } catch { - // Reported below as the same refusal a build gives. + const handle = await open(path, "r"); + try { + const found = await handle.stat(); + if (found.isFile()) return ok(found.size); + } finally { + await handle.close(); + } + } catch (cause: unknown) { + const code = + cause instanceof Error && "code" in cause ? String(cause.code) : ""; + if (code === "EACCES" || code === "EPERM") + return err( + new AnalysisAccessDeniedError( + ENVIRONMENT_EXPORT_OPERATION, + path, + code, + { + cause, + }, + ), + ); } return err( new AnalysisInputError( @@ -247,6 +329,10 @@ export const buildEnvironment = async ( ): Promise> => { if (input.directory === undefined && !input.dryRun) return err(missingDirectory()); + if (new Set(input.templateRules).size !== input.templateRules.length) + return err(repeatedRules()); + if (input.captureInput === "evidence" && harDocument(input.capture.value)) + return err(harAsJson(input.capturePath)); const evidence = parseEvidenceFile( ENVIRONMENT_BUILD_OPERATION, "evidence", @@ -266,6 +352,8 @@ export const buildEnvironment = async ( (id) => (id === capture.evidence_id ? capture : undefined), ); if (!projected.ok) return projected; + const stopped = cancelled(input.signal); + if (stopped !== undefined) return err(stopped); const manifest = await environmentExportFile( "manifest", "manifest.json", @@ -283,6 +371,8 @@ export const buildEnvironment = async ( const named = environmentName(input, source.value.manifest, warnings); if (!named.ok) return named; const name = named.value ?? source.value.manifest.name; + const beforeState = cancelled(input.signal); + if (beforeState !== undefined) return err(beforeState); input.progress?.("state"); // Prepared as the export prepares it, so its report and the bundle's README match the three-step path. const served = prepareServedState(source.value, input.capture.value, { @@ -331,6 +421,8 @@ export const buildEnvironment = async ( warnings, }; if (input.dryRun) { + const beforePlan = cancelled(input.signal); + if (beforePlan !== undefined) return err(beforePlan); const plan = planEnvironmentBundle(source.value, { name, packageSource, @@ -365,6 +457,8 @@ export const buildEnvironment = async ( } const directory = input.directory; if (directory === undefined) return err(missingDirectory()); + const beforeWrite = cancelled(input.signal); + if (beforeWrite !== undefined) return err(beforeWrite); input.progress?.("bundle"); const exported = await exportEnvironmentBundle({ manifest: manifest.value, @@ -376,13 +470,16 @@ export const buildEnvironment = async ( packageTarball: input.packageTarball, }); if (!exported.ok) return exported; - return ok({ + const report: EnvironmentBuildReport = { status: "built", directory, ...base, tool_count: exported.value.tool_count, package_source: exported.value.package_source, files: exported.value.files, - next: [`agent-env run ${directory}`], - }); + next: [`agent-env run ${shellWord(directory)}`], + }; + // A cancellation that arrives during the write still names the bundle it wrote. + const afterWrite = cancelled(input.signal, directory); + return afterWrite === undefined ? ok(report) : err(afterWrite); }; diff --git a/src/application/environment/environmentBuildReport.test.ts b/src/application/environment/environmentBuildReport.test.ts index 9fbde41f52..10ceb1d03a 100644 --- a/src/application/environment/environmentBuildReport.test.ts +++ b/src/application/environment/environmentBuildReport.test.ts @@ -51,6 +51,12 @@ describe("credentialNamed", () => { "client_secret", "X-Session", "refresh.token", + "Authorization", + "APIToken", + "accesstoken", + "passphrase", + "api_keys", + "ssh_key", ]) expect(credentialNamed(name), name).toBe(true); for (const name of ["author", "authority_level", "keyboard", "tokenizer"]) @@ -78,6 +84,61 @@ describe("scanCredentialNames", () => { }); expect(JSON.stringify(scanned)).not.toContain("s3cr3t"); }); + + it("scans header and query names, form bodies and string arrays", () => { + const withHeaders = { + ordinal: 7, + reported: { + request: { + method: "POST", + url: "http://127.0.0.1/api?api_key=k&page=1", + headers: [ + { name: "X-Api-Key", value: "k-123" }, + { name: "Accept", value: "application/json" }, + { name: "Authorization", value: null }, + ], + queryString: [ + { name: "api_key", value: "k" }, + { name: "page", value: "1" }, + ], + postData: { + mimeType: "application/x-www-form-urlencoded", + text: "user=a&password=hunter22", + }, + }, + response: { + status: 200, + headers: [{ name: "X-Auth-Token", value: "t-9" }], + content: { + mimeType: "application/json", + text: JSON.stringify({ tokens: ["a", "b"], token_count: 2 }), + }, + }, + }, + redactions: [], + binary_fields: [], + } as unknown as WebNetworkCaptureRecord; + expect(scanCredentialNames([withHeaders])).toEqual({ + count: 5, + pointers: [ + "record 7 request header X-Api-Key", + "record 7 response header X-Auth-Token", + "record 7 request query api_key", + "record 7 request /password", + "record 7 response /tokens", + ], + }); + }); + + it("walks bodies nested deeper than the call stack allows", () => { + const depth = 20_000; + const deep = record(1, undefined, undefined); + ( + deep.reported as { response: { content: { text: string } } } + ).response.content.text = + `${"[".repeat(depth)}{"token":"x"}${"]".repeat(depth)}`; + expect(scanCredentialNames([deep]).count).toBe(1); + }); }); describe("anonymousOrigin", () => { @@ -90,6 +151,7 @@ describe("anonymousOrigin", () => { "http://10.0.0.5", ]) expect(anonymousOrigin(origin), origin).toBe(true); + expect(anonymousOrigin("http://localhost.:3000")).toBe(true); for (const origin of ["https://grafana.example", "not a url"]) expect(anonymousOrigin(origin), origin).toBe(false); }); diff --git a/src/application/environment/environmentBuildReport.ts b/src/application/environment/environmentBuildReport.ts index f2afe13c21..78624a3d20 100644 --- a/src/application/environment/environmentBuildReport.ts +++ b/src/application/environment/environmentBuildReport.ts @@ -13,14 +13,21 @@ export interface EnvironmentBuildWarning { /** Whole name segments that name credentials; `author` is not `auth`. */ const CREDENTIAL_SEGMENTS = new Set([ + "accesstoken", + "apikey", + "apikeys", + "apitoken", "auth", + "authorization", "bearer", "cookie", "credential", "credentials", "jwt", + "passphrase", "passwd", "password", + "privatekey", "pwd", "secret", "secrets", @@ -28,26 +35,27 @@ const CREDENTIAL_SEGMENTS = new Set([ "sessionid", "token", "tokens", - "apikey", - "privatekey", ]); const CREDENTIAL_PAIRS = new Set([ "access key", "api key", + "api keys", "client secret", "private key", "session id", + "ssh key", ]); /** Split a member name into lowercase segments at case changes and separators. */ const nameSegments = (name: string): string[] => name .replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2") + .replaceAll(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2") .toLowerCase() .split(/[^a-z0-9]+/u) .filter((segment) => segment.length > 0); -/** Whether a body member's name looks like it holds a credential. */ +/** Whether a member, header or query name looks like it holds a credential. */ export const credentialNamed = (name: string): boolean => { const segments = nameSegments(name); return segments.some( @@ -65,61 +73,104 @@ const REPORTED_POINTERS = 20; const pointerToken = (name: string): string => name.replaceAll("~", "~0").replaceAll("/", "~1"); -const parsedBody = (text: string | null | undefined): unknown => { +/** A held value: a non-empty string, or an array holding one. */ +const holdsValue = (value: unknown): boolean => + (typeof value === "string" && value !== "") || + (Array.isArray(value) && + value.some((item) => typeof item === "string" && item !== "")); + +const member = (value: unknown, name: string): unknown => + typeof value === "object" && value !== null + ? Reflect.get(value, name) + : undefined; + +/** A recorded body as JSON or form members, or undefined when it is neither. */ +const parsedBody = (text: unknown): unknown => { if (typeof text !== "string" || text.length > SCAN_BODY_LIMIT_BYTES) return undefined; const start = text.trimStart()[0]; - if (start !== "{" && start !== "[") return undefined; + if (start === "{" || start === "[") { + try { + return JSON.parse(text); + } catch { + return undefined; + } + } + if (!/^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/u.test(text)) return undefined; try { - return JSON.parse(text); + return Object.fromEntries(new URLSearchParams(text)); } catch { return undefined; } }; -const requestText = (record: WebNetworkCaptureRecord): string | undefined => { - const reported: unknown = record.reported; - if (typeof reported !== "object" || reported === null) return undefined; - const request: unknown = Reflect.get(reported, "request"); - if (typeof request !== "object" || request === null) return undefined; - const postData: unknown = Reflect.get(request, "postData"); - if (typeof postData !== "object" || postData === null) return undefined; - const text: unknown = Reflect.get(postData, "text"); - return typeof text === "string" ? text : undefined; -}; +/** Name/value pairs such as HAR headers and query strings. */ +const namedValues = (value: unknown): (readonly [string, unknown])[] => + Array.isArray(value) + ? value.flatMap((entry: unknown) => { + const name = member(entry, "name"); + return typeof name === "string" + ? [[name, member(entry, "value")] as const] + : []; + }) + : []; -/** Credential-named members with a non-empty string value in recorded JSON bodies: a count and the first pointers, never values. */ +/** + * Credential-named members that hold a value in recorded JSON and form + * bodies, headers and query strings: a count and the first pointers, never + * values. The walk keeps its own stack, so nesting depth cannot overflow it. + */ export const scanCredentialNames = ( records: readonly WebNetworkCaptureRecord[], ): { readonly count: number; readonly pointers: readonly string[] } => { let count = 0; const pointers: string[] = []; - const visit = (value: unknown, pointer: string, label: string): void => { - if (Array.isArray(value)) { - value.forEach((child, index) => - visit(child, `${pointer}/${String(index)}`, label), - ); - return; - } - if (typeof value !== "object" || value === null) return; - for (const [name, child] of Object.entries(value)) { - const path = `${pointer}/${pointerToken(name)}`; - if (credentialNamed(name) && typeof child === "string" && child !== "") { - count++; - if (pointers.length < REPORTED_POINTERS) - pointers.push(`${label} ${path}`); + const found = (pointer: string): void => { + count++; + if (pointers.length < REPORTED_POINTERS) pointers.push(pointer); + }; + const walk = (root: unknown, label: string): void => { + const stack: [unknown, string][] = [[root, ""]]; + while (stack.length > 0) { + const [value, pointer] = stack.pop()!; + if (typeof value !== "object" || value === null) continue; + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index--) + stack.push([value[index], `${pointer}/${String(index)}`]); + continue; + } + const entries = Object.entries(value); + for (let index = entries.length - 1; index >= 0; index--) { + const [name, child] = entries[index]!; + const path = `${pointer}/${pointerToken(name)}`; + if (credentialNamed(name) && holdsValue(child)) + found(`${label} ${path}`); + stack.push([child, path]); } - visit(child, path, label); } }; for (const record of records) { const ordinal = String(record.ordinal); - visit(parsedBody(requestText(record)), "", `record ${ordinal} request`); - visit( + const request = member(record.reported, "request"); + const response = member(record.reported, "response"); + for (const [side, message] of [ + ["request", request], + ["response", response], + ] as const) + for (const [name, value] of namedValues(member(message, "headers"))) + if (credentialNamed(name) && holdsValue(value)) + found(`record ${ordinal} ${side} header ${name}`); + for (const [name, value] of namedValues(member(request, "queryString"))) + if (credentialNamed(name) && holdsValue(value)) + found(`record ${ordinal} request query ${name}`); + walk( + parsedBody(member(member(request, "postData"), "text")), + `record ${ordinal} request`, + ); + walk( parsedBody( readRecordedResponseBody(record, SCAN_BODY_LIMIT_BYTES).bodyText, ), - "", `record ${ordinal} response`, ); } @@ -134,7 +185,10 @@ export const anonymousOrigin = (origin: string): boolean => { } catch { return false; } - const bare = host.startsWith("[") ? host.slice(1, -1) : host; + const bracketless = host.startsWith("[") ? host.slice(1, -1) : host; + const bare = bracketless.endsWith(".") + ? bracketless.slice(0, -1) + : bracketless; return ( bare === "localhost" || bare.endsWith(".localhost") || isIP(bare) !== 0 ); diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts index 685f1653b6..9dd3f9de27 100644 --- a/src/application/environment/environmentBundle.ts +++ b/src/application/environment/environmentBundle.ts @@ -30,6 +30,12 @@ const BUNDLE_NAME_MAX_LENGTH = 64; /** Win32 reserves these device names for files and folders, so the env folder can never take one. */ const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u; +/** Whether a name can be the env folder and env id: the checks the export applies. */ +export const isPortableBundleName = (name: string): boolean => + BUNDLE_NAME.test(name) && + name.length <= BUNDLE_NAME_MAX_LENGTH && + !WINDOWS_RESERVED_NAME.test(name); + /** Where the Dockerfile installs the CLI from. */ export type EnvironmentPackageSource = | { readonly kind: "registry"; readonly specifier: string } diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index 5da11196ec..ac891a26ab 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -32,6 +32,7 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { createLogger } from "../logger.js"; import { TEMPLATE_RULES } from "../domain/analysisView/environmentManifestRules.js"; +import { inspectWebNetworkCaptureInputSchema } from "../domain/webNetworkCapture.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import type { CliInstance } from "./types.js"; @@ -321,6 +322,7 @@ interface BuildArguments { readonly formatRulesOnly: boolean; readonly packageTarball?: string | undefined; readonly dryRun: boolean; + readonly captureTimeoutMs?: number | undefined; } const conflictingRules = (): JsonValue => @@ -344,6 +346,7 @@ const isEvidenceValue = (value: unknown): boolean => /** Inspect a HAR as inspect-web-network-capture does and serialize the Evidence as it prints it; a refusal is the command's output. */ const inspectBuildHar = async ( capturePath: string, + captureTimeoutMs: number | undefined, environment: Readonly, signal: AbortSignal, ): Promise> => { @@ -357,7 +360,16 @@ const inspectBuildHar = async ( const service = createWebNetworkCaptureService(environment); const inspected = await finishAnalysisCommand( () => - service.inspect({ capture_path: capturePath, format: "har" }, { signal }), + service.inspect( + { + capture_path: capturePath, + format: "har", + ...(captureTimeoutMs === undefined + ? {} + : { capture_timeout_ms: captureTimeoutMs }), + }, + { signal }, + ), service, "web-network-capture", "inspect_web_network_capture", @@ -395,6 +407,20 @@ const buildEnvironmentCommand = async ( const capturePath = absoluteCallerPath(input.capture); const kind = classifyBuildCapture(capturePath); if (!kind.ok) return cliError(kind.error); + if (input.directory === undefined && !input.dryRun) + return cliError( + new AnalysisInputError( + CLI_COMMANDS.environmentBuild, + { userMessage: "Name the bundle directory, or pass --dry-run." }, + [ + { + path: ["directory"], + reason: "invalid_value", + message: "The bundle directory may be omitted only with --dry-run.", + }, + ], + ), + ); const commandLogger = infoLogger(environment, logger); const progress = (stage: string): void => { commandLogger.info({ stage }, "Building environment"); @@ -402,7 +428,12 @@ const buildEnvironmentCommand = async ( let capture: EnvironmentExportFile; if (kind.value === "har") { progress("inspect"); - const inspected = await inspectBuildHar(capturePath, environment, signal); + const inspected = await inspectBuildHar( + capturePath, + input.captureTimeoutMs, + environment, + signal, + ); if (!inspected.ok) return inspected.error; capture = inspected.value; } else { @@ -437,6 +468,7 @@ const buildEnvironmentCommand = async ( : absoluteCallerPath(input.packageTarball), dryRun: input.dryRun, progress, + signal, }); return built.ok ? (built.value as unknown as JsonValue) @@ -492,6 +524,8 @@ const registerBuildCommand = ( .boolean() .default(false) .describe("Run every stage and report, but write nothing"), + captureTimeoutMs: + inspectWebNetworkCaptureInputSchema.shape.capture_timeout_ms, }), run: ({ args, options }) => withCommandCancellation((signal) => @@ -505,6 +539,7 @@ const registerBuildCommand = ( formatRulesOnly: options.formatRulesOnly, packageTarball: options.packageTarball, dryRun: options.dryRun, + captureTimeoutMs: options.captureTimeoutMs, }, environment, logger, diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index cb19115c38..09fcb44972 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/domain/analysisView/environmentCoverageView.ts b/src/domain/analysisView/environmentCoverageView.ts index bbbf71ca4a..e2a836ec6f 100644 --- a/src/domain/analysisView/environmentCoverageView.ts +++ b/src/domain/analysisView/environmentCoverageView.ts @@ -34,7 +34,7 @@ export const WITHHELD_WRITE_HINTS = { "No recorded call of this tool was modelled, for example because every call failed. Record a successful call.", "body-unusable": "The request body is not a usable JSON object.", "no-observation": - "Nothing showed the written entity. Read it back by id right after the write.", + "Nothing showed the entity or document this write changed. Read a created entity back by id right after the write; for an update or delete the state must already hold the entity, so read it before the write or record the create that made it.", "unexplained-change": "The write changed fields it did not send, in a way no rule explains. A second, different call of the same tool can show the pattern.", contradicted: @@ -171,7 +171,7 @@ export const projectEnvironmentCoverageView = ( coverage: state.coverage, limitations: [ ...state.limitations, - "Coverage summarizes the environment-state view the environment derives at startup; it does not run the environment. Hints are advice about what to record next.", + "Coverage summarizes the environment-state view an environment derives at startup; it does not run the environment. An environment whose served catalog exceeds the discovery budget, or whose state exceeds what data/add loads, serves replay instead, which this view does not check. Hints are advice about what to record next.", ], unknowns: state.unknowns, origin: state.origin,