diff --git a/src/server/containment/index.ts b/src/server/containment/index.ts new file mode 100644 index 000000000..62747f14d --- /dev/null +++ b/src/server/containment/index.ts @@ -0,0 +1,567 @@ +import { createHash } from "node:crypto"; +import { isIP } from "node:net"; +import path from "node:path"; + +export type ContainmentDestination = { + host: string; + port: number; + protocol?: "tcp" | "udp"; +}; + +export type ContainmentDnsPolicy = + | { mode: "disabled" | "system" } + | { + mode: "pinned"; + records: readonly { + hostname: string; + addresses: readonly string[]; + }[]; + }; + +export type ContainmentMount = { + source: string; + target: string; + access: "read-only" | "read-write"; +}; + +export type ContainmentApprovalIntent = { + action: string; + normalized: string; + durableDecisionId?: string; +}; + +export type ContainmentPolicyInput = { + approvedTargetIds: readonly string[]; + pinnedDestinations: readonly ContainmentDestination[]; + dnsPolicy: ContainmentDnsPolicy; + mounts: readonly ContainmentMount[]; + capabilities: { + allowed: readonly string[]; + dropped: readonly string[]; + }; + resourceLimits: { + cpuCount: number; + memoryBytes: number; + pids: number; + maxRuntimeMs: number; + }; + isolationMode: "process" | "container" | "microvm"; + expiresAt: string; + approvalIntent: ContainmentApprovalIntent; +}; + +export type ContainmentExecutionScope = { + targetIds: readonly string[]; + destinations: readonly ContainmentDestination[]; + approvalIntent: ContainmentApprovalIntent; +}; + +export type ContainmentPolicySnapshot = Readonly<{ + schemaVersion: 1; + id: `containment:sha256:${string}`; + contentHash: `sha256:${string}`; + approvedTargetIds: readonly string[]; + pinnedDestinations: readonly Readonly>[]; + dnsPolicy: Readonly< + | { mode: "disabled" | "system" } + | { + mode: "pinned"; + records: readonly Readonly<{ + hostname: string; + addresses: readonly string[]; + }>[]; + } + >; + mounts: readonly Readonly[]; + capabilities: Readonly<{ + allowed: readonly string[]; + dropped: readonly string[]; + }>; + resourceLimits: Readonly; + isolationMode: ContainmentPolicyInput["isolationMode"]; + expiresAt: string; + approvalIntent: Readonly; +}>; + +export type ContainmentRuntimeProjection = Readonly<{ + policySnapshotId: ContainmentPolicySnapshot["id"]; + policyContentHash: ContainmentPolicySnapshot["contentHash"]; + isolationMode: ContainmentPolicySnapshot["isolationMode"]; + network: Readonly<{ + enabled: boolean; + pinnedDestinations: ContainmentPolicySnapshot["pinnedDestinations"]; + dnsPolicy: ContainmentPolicySnapshot["dnsPolicy"]; + }>; + mounts: ContainmentPolicySnapshot["mounts"]; + capabilities: ContainmentPolicySnapshot["capabilities"]; + resourceLimits: ContainmentPolicySnapshot["resourceLimits"]; + expiresAt: string; + approvalIntent: ContainmentPolicySnapshot["approvalIntent"]; +}>; + +export type ContainmentEvalProjection = Readonly<{ + policySnapshotId: ContainmentPolicySnapshot["id"]; + policyContentHash: ContainmentPolicySnapshot["contentHash"]; + schemaVersion: 1; + admittedAt: string; + expiresAt: string; + isolationMode: ContainmentPolicySnapshot["isolationMode"]; + approvedTargetIds: ContainmentPolicySnapshot["approvedTargetIds"]; + requestedTargetIds: readonly string[]; + approvedDestinations: ContainmentPolicySnapshot["pinnedDestinations"]; + requestedDestinations: ContainmentPolicySnapshot["pinnedDestinations"]; + dnsPolicy: ContainmentPolicySnapshot["dnsPolicy"]; + mounts: ContainmentPolicySnapshot["mounts"]; + capabilities: ContainmentPolicySnapshot["capabilities"]; + resourceLimits: ContainmentPolicySnapshot["resourceLimits"]; + approvalIntent: ContainmentPolicySnapshot["approvalIntent"]; +}>; + +export type ContainmentPolicyResolution = Readonly<{ + snapshot: ContainmentPolicySnapshot; + runtime: ContainmentRuntimeProjection; + evaluation: ContainmentEvalProjection; +}>; + +export type ContainmentPolicyDiagnostic = Readonly<{ + code: + | "invalid-policy" + | "policy-expired" + | "target-scope-mismatch" + | "destination-scope-mismatch" + | "approval-intent-mismatch"; + message: string; + field: string; + expected?: unknown; + actual?: unknown; + policySnapshotId?: string; +}>; + +export class ContainmentPolicyError extends Error { + constructor(readonly diagnostic: ContainmentPolicyDiagnostic) { + super(diagnostic.message); + this.name = "ContainmentPolicyError"; + } +} + +/** + * The single public containment seam. It converts caller input into one immutable, + * content-addressed policy, rejects any expired or widened execution scope, and + * emits projections for the runtime enforcer and evaluation evidence. + */ +export function resolveContainmentPolicy(input: { + policy: ContainmentPolicyInput; + scope: ContainmentExecutionScope; + now?: Date | string; +}): ContainmentPolicyResolution { + const unsigned = normalizePolicy(input.policy); + const contentHash = `sha256:${sha256(stableJson(unsigned))}` as const; + const snapshot = deepFreeze({ + ...unsigned, + id: `containment:${contentHash}` as const, + contentHash, + }); + const admittedAt = normalizeDate(input.now ?? new Date(), "now"); + const scope = normalizeScope(input.scope); + + if (Date.parse(admittedAt) >= Date.parse(snapshot.expiresAt)) { + deny(snapshot, { + code: "policy-expired", + message: "Containment policy expired before execution admission.", + field: "expiresAt", + expected: `later than ${admittedAt}`, + actual: snapshot.expiresAt, + }); + } + + const unapprovedTargets = difference( + scope.targetIds, + snapshot.approvedTargetIds, + ); + if (unapprovedTargets.length > 0) { + deny(snapshot, { + code: "target-scope-mismatch", + message: "Execution requested target ids outside the containment policy.", + field: "targetIds", + expected: snapshot.approvedTargetIds, + actual: unapprovedTargets, + }); + } + + const approvedDestinationKeys = new Set( + snapshot.pinnedDestinations.map(destinationKey), + ); + const unapprovedDestinations = scope.destinations.filter( + (destination) => !approvedDestinationKeys.has(destinationKey(destination)), + ); + if (unapprovedDestinations.length > 0) { + deny(snapshot, { + code: "destination-scope-mismatch", + message: + "Execution requested destinations outside the containment policy.", + field: "destinations", + expected: snapshot.pinnedDestinations, + actual: unapprovedDestinations, + }); + } + + if ( + stableJson(scope.approvalIntent) !== stableJson(snapshot.approvalIntent) + ) { + deny(snapshot, { + code: "approval-intent-mismatch", + message: + "Execution approval intent does not match the containment policy.", + field: "approvalIntent", + expected: snapshot.approvalIntent, + actual: scope.approvalIntent, + }); + } + + const runtime = deepFreeze({ + policySnapshotId: snapshot.id, + policyContentHash: snapshot.contentHash, + isolationMode: snapshot.isolationMode, + network: { + enabled: snapshot.pinnedDestinations.length > 0, + pinnedDestinations: snapshot.pinnedDestinations, + dnsPolicy: snapshot.dnsPolicy, + }, + mounts: snapshot.mounts, + capabilities: snapshot.capabilities, + resourceLimits: snapshot.resourceLimits, + expiresAt: snapshot.expiresAt, + approvalIntent: snapshot.approvalIntent, + }); + const evaluation = deepFreeze({ + policySnapshotId: snapshot.id, + policyContentHash: snapshot.contentHash, + schemaVersion: 1 as const, + admittedAt, + expiresAt: snapshot.expiresAt, + isolationMode: snapshot.isolationMode, + approvedTargetIds: snapshot.approvedTargetIds, + requestedTargetIds: scope.targetIds, + approvedDestinations: snapshot.pinnedDestinations, + requestedDestinations: scope.destinations, + dnsPolicy: snapshot.dnsPolicy, + mounts: snapshot.mounts, + capabilities: snapshot.capabilities, + resourceLimits: snapshot.resourceLimits, + approvalIntent: snapshot.approvalIntent, + }); + + return deepFreeze({ snapshot, runtime, evaluation }); +} + +function normalizePolicy( + policy: ContainmentPolicyInput, +): Omit { + const expiresAt = normalizeDate(policy.expiresAt, "expiresAt"); + const approvedTargetIds = normalizeStringSet( + policy.approvedTargetIds, + "approvedTargetIds", + ); + const pinnedDestinations = normalizeDestinations(policy.pinnedDestinations); + const mounts = uniqueBy( + policy.mounts.map((mount, index) => { + if (mount.access !== "read-only" && mount.access !== "read-write") { + invalid( + `mounts[${index}].access`, + "Mount access must be read-only or read-write.", + mount.access, + ); + } + return { + source: normalizeAbsolutePath(mount.source, `mounts[${index}].source`), + target: normalizeAbsolutePath(mount.target, `mounts[${index}].target`), + access: mount.access, + }; + }), + (mount) => mount.target, + "mount target", + ).sort((left, right) => left.target.localeCompare(right.target)); + const allowed = normalizeStringSet( + policy.capabilities.allowed, + "capabilities.allowed", + ); + const dropped = normalizeStringSet( + policy.capabilities.dropped, + "capabilities.dropped", + ); + const droppedCapabilities = new Set(dropped); + const overlappingCapabilities = allowed.filter((capability) => + droppedCapabilities.has(capability), + ); + if (overlappingCapabilities.length > 0) { + invalid( + "capabilities", + "Capabilities cannot be both allowed and dropped.", + overlappingCapabilities, + ); + } + + return { + schemaVersion: 1, + approvedTargetIds, + pinnedDestinations, + dnsPolicy: normalizeDnsPolicy(policy.dnsPolicy), + mounts, + capabilities: { allowed, dropped }, + resourceLimits: { + cpuCount: positiveNumber( + policy.resourceLimits.cpuCount, + "resourceLimits.cpuCount", + ), + memoryBytes: positiveInteger( + policy.resourceLimits.memoryBytes, + "resourceLimits.memoryBytes", + ), + pids: positiveInteger(policy.resourceLimits.pids, "resourceLimits.pids"), + maxRuntimeMs: positiveInteger( + policy.resourceLimits.maxRuntimeMs, + "resourceLimits.maxRuntimeMs", + ), + }, + isolationMode: normalizeIsolationMode(policy.isolationMode), + expiresAt, + approvalIntent: normalizeApprovalIntent(policy.approvalIntent), + }; +} + +function normalizeScope(scope: ContainmentExecutionScope) { + return deepFreeze({ + targetIds: normalizeStringSet(scope.targetIds, "scope.targetIds"), + destinations: normalizeDestinations(scope.destinations), + approvalIntent: normalizeApprovalIntent(scope.approvalIntent), + }); +} + +function normalizeDestinations( + destinations: readonly ContainmentDestination[], +) { + return uniqueBy( + destinations.map((destination, index) => { + const host = requiredString( + destination.host, + `destinations[${index}].host`, + ) + .toLowerCase() + .replace(/\.$/u, ""); + if (!/^[a-z0-9:[\]._-]+$/u.test(host)) { + invalid( + `destinations[${index}].host`, + "Destination host contains unsafe characters.", + host, + ); + } + const port = positiveInteger( + destination.port, + `destinations[${index}].port`, + ); + if (port > 65_535) { + invalid( + `destinations[${index}].port`, + "Destination port must not exceed 65535.", + port, + ); + } + const protocol = destination.protocol ?? "tcp"; + if (protocol !== "tcp" && protocol !== "udp") { + invalid( + `destinations[${index}].protocol`, + "Destination protocol must be tcp or udp.", + protocol, + ); + } + return { host, port, protocol } as const; + }), + destinationKey, + "destination", + ).sort((left, right) => + destinationKey(left).localeCompare(destinationKey(right)), + ); +} + +function normalizeDnsPolicy( + policy: ContainmentDnsPolicy, +): ContainmentPolicySnapshot["dnsPolicy"] { + if (policy.mode !== "pinned") return { mode: policy.mode }; + const records = uniqueBy( + policy.records.map((record, index) => { + const hostname = requiredString( + record.hostname, + `dnsPolicy.records[${index}].hostname`, + ) + .toLowerCase() + .replace(/\.$/u, ""); + if (!/^[a-z0-9._-]+$/u.test(hostname)) { + invalid( + `dnsPolicy.records[${index}].hostname`, + "Pinned DNS hostname contains unsafe characters.", + hostname, + ); + } + const addresses = normalizeStringSet( + record.addresses.map((address) => address.toLowerCase()), + `dnsPolicy.records[${index}].addresses`, + ); + if ( + addresses.length === 0 || + addresses.some((address) => isIP(address) === 0) + ) { + invalid( + `dnsPolicy.records[${index}].addresses`, + "Pinned DNS records require literal IPv4 or IPv6 addresses.", + addresses, + ); + } + return { hostname, addresses }; + }), + (record) => record.hostname, + "DNS hostname", + ).sort((left, right) => left.hostname.localeCompare(right.hostname)); + if (records.length === 0) { + invalid( + "dnsPolicy.records", + "Pinned DNS policy requires at least one record.", + records, + ); + } + return { mode: "pinned", records }; +} + +function normalizeApprovalIntent(intent: ContainmentApprovalIntent) { + return { + action: requiredString(intent.action, "approvalIntent.action"), + normalized: requiredString(intent.normalized, "approvalIntent.normalized"), + ...(intent.durableDecisionId + ? { + durableDecisionId: requiredString( + intent.durableDecisionId, + "approvalIntent.durableDecisionId", + ), + } + : {}), + }; +} + +function normalizeIsolationMode( + value: ContainmentPolicyInput["isolationMode"], +) { + if (value !== "process" && value !== "container" && value !== "microvm") { + invalid( + "isolationMode", + "Isolation mode must be process, container, or microvm.", + value, + ); + } + return value; +} + +function normalizeAbsolutePath(value: string, field: string) { + const normalized = path.posix.normalize(requiredString(value, field)); + if (!normalized.startsWith("/")) + invalid(field, "Containment mount paths must be absolute.", value); + return normalized; +} + +function normalizeDate(value: Date | string, field: string) { + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) + invalid(field, "Expected a valid timestamp.", value); + return date.toISOString(); +} + +function normalizeStringSet(values: readonly string[], field: string) { + return [ + ...new Set( + values.map((value, index) => requiredString(value, `${field}[${index}]`)), + ), + ].sort(); +} + +function requiredString(value: string, field: string) { + const normalized = value.trim(); + if (!normalized) invalid(field, "Expected a non-empty string.", value); + return normalized; +} + +function positiveNumber(value: number, field: string) { + if (!Number.isFinite(value) || value <= 0) + invalid(field, "Expected a positive number.", value); + return value; +} + +function positiveInteger(value: number, field: string) { + if (!Number.isSafeInteger(value) || value <= 0) { + invalid(field, "Expected a positive integer.", value); + } + return value; +} + +function uniqueBy( + values: readonly T[], + key: (value: T) => string, + label: string, +) { + const seen = new Set(); + for (const value of values) { + const identity = key(value); + if (seen.has(identity)) + invalid(label, `Duplicate ${label} is not allowed.`, identity); + seen.add(identity); + } + return [...values]; +} + +function difference(values: readonly string[], allowed: readonly string[]) { + const allowedSet = new Set(allowed); + return values.filter((value) => !allowedSet.has(value)); +} + +function destinationKey(destination: Required) { + return `${destination.protocol}:${destination.host}:${destination.port}`; +} + +function deny( + snapshot: ContainmentPolicySnapshot, + diagnostic: Omit, +): never { + throw new ContainmentPolicyError({ + ...diagnostic, + policySnapshotId: snapshot.id, + }); +} + +function invalid(field: string, message: string, actual: unknown): never { + throw new ContainmentPolicyError({ + code: "invalid-policy", + field, + message, + actual, + }); +} + +function sha256(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function deepFreeze(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value)) deepFreeze(nested); + } + return value; +} diff --git a/src/server/evidence/ingestion.ts b/src/server/evidence/ingestion.ts index a5554206a..20395c62a 100644 --- a/src/server/evidence/ingestion.ts +++ b/src/server/evidence/ingestion.ts @@ -23,6 +23,8 @@ export const EVIDENCE_SOURCES = [ "patch-verification", "patch-remediation", "stage-handoff", + "passive-recon", + "containment-policy", ] as const; export type EvidenceSource = (typeof EVIDENCE_SOURCES)[number]; diff --git a/src/server/security-actions/execution.ts b/src/server/security-actions/execution.ts index 0c4a2bbe2..728bf3923 100644 --- a/src/server/security-actions/execution.ts +++ b/src/server/security-actions/execution.ts @@ -8,6 +8,13 @@ import type { DurableDecisionRepository, } from "../approvals/types"; import type { ToolRunCreateInput, ToolRunFinishInput, ToolRunRecord } from "../chat/types"; +import { + ContainmentPolicyError, + type ContainmentPolicyInput, + type ContainmentPolicyResolution, + resolveContainmentPolicy, +} from "../containment"; +import { getArtifactService } from "../evidence"; import type { TargetAuthorizationCoverage } from "../targets/authorization-ledger"; export type SecurityActionIntent = { @@ -58,16 +65,42 @@ export type SecurityActionExecutionDependencies = { intent: NormalizedSecurityActionIntent; output?: unknown; error?: string; + containment?: SecurityActionContainmentAudit; }) => Promise; + persistContainmentSnapshot?: (input: { + projectId: string; + threadId?: string; + toolRunId: string; + mode: SecurityActionContainmentMode; + resolution: ContainmentPolicyResolution; + }) => Promise; now?: () => Date; }; +export type SecurityActionContainmentMode = "shadow-v1" | "enforce-v1"; + +export type SecurityActionContainmentAudit = { + mode: SecurityActionContainmentMode; + policySnapshotId?: string; + policyContentHash?: string; + snapshotArtifactId?: string; + diagnostic?: { + code: string; + message: string; + field?: string; + }; +}; + export type SecurityActionExecutionInput = { projectId: string; threadId?: string; intent: SecurityActionIntent; durableApprovalId?: string; approvalAction?: DurableApprovalAction; + containment?: { + mode: SecurityActionContainmentMode; + policy: ContainmentPolicyInput; + }; constraints?: { allowedNetworkProfiles?: readonly string[]; allowedWorkspacePrefixes?: readonly string[]; @@ -78,6 +111,7 @@ export type SecurityActionExecutionInput = { toolRunId: string; intent: NormalizedSecurityActionIntent; authorization?: Extract; + containment?: ContainmentPolicyResolution; }) => Promise; }; @@ -90,6 +124,8 @@ export type SecurityActionExecutionResult = { toolRunId: string; output: TOutput; artifactId?: string; + containmentPolicySnapshotId?: string; + containmentSnapshotArtifactId?: string; }; export class SecurityActionDeniedError extends Error { @@ -167,17 +203,52 @@ export function createSecurityActionExecutor(dependencies: SecurityActionExecuti ): Promise> { const intent = normalizeSecurityActionIntent(request.intent); const startedAt = now().toISOString(); + const containment = resolveSecurityActionContainment(request, intent, startedAt); const run = await dependencies.toolRuns.create(request.projectId, { ...(request.threadId ? { threadId: request.threadId } : {}), toolName: intent.capabilityId, status: "running", input: redact(intent.input) as Record, startedAt, - metadata: { normalizedIntent: intent.approvalIntent.normalized }, + metadata: { + normalizedIntent: intent.approvalIntent.normalized, + ...(containment.audit ? { containment: containment.audit } : {}), + }, }); + if (containment.resolution && request.containment) { + const audit = containment.audit ?? { mode: request.containment.mode }; + try { + const reference = await persistContainmentSnapshot( + dependencies.persistContainmentSnapshot, + { + projectId: request.projectId, + ...(request.threadId ? { threadId: request.threadId } : {}), + toolRunId: run.id, + mode: request.containment.mode, + resolution: containment.resolution, + }, + ); + containment.audit = { + ...audit, + snapshotArtifactId: reference.artifactId, + }; + } catch (cause) { + containment.audit = { + ...audit, + diagnostic: { + code: "containment-audit-persistence-failed", + message: redactError(cause, redact), + }, + }; + } + } + let authorization: Extract | undefined; try { + if (request.containment?.mode === "enforce-v1" && containment.audit?.diagnostic) { + deny(containment.audit.diagnostic.code, containment.audit.diagnostic.message); + } const capability = getSecurityCapability(intent.capabilityId); if (!capability) deny("unknown-capability", `Unknown capability ${intent.capabilityId}.`); enforceConstraints(intent, request.constraints); @@ -240,6 +311,7 @@ export function createSecurityActionExecutor(dependencies: SecurityActionExecuti toolRunId: run.id, intent, ...(authorization ? { authorization } : {}), + ...(containment.resolution ? { containment: containment.resolution } : {}), }); const redactedOutput = redact(output); const existingArtifactId = readArtifactId(output); @@ -251,6 +323,7 @@ export function createSecurityActionExecutor(dependencies: SecurityActionExecuti toolRunId: run.id, intent, output: redactedOutput, + ...(containment.audit ? { containment: containment.audit } : {}), }); await dependencies.toolRuns.finish(request.projectId, run.id, { status: "succeeded", @@ -260,15 +333,27 @@ export function createSecurityActionExecutor(dependencies: SecurityActionExecuti ? { artifactIds: [evidence.reference.artifactId], ...(evidence.reference.metadata ?? {}), + ...(containment.audit ? { containment: containment.audit } : {}), } : evidence.error - ? { evidenceCaptureError: evidence.error } - : {}, + ? { + evidenceCaptureError: evidence.error, + ...(containment.audit ? { containment: containment.audit } : {}), + } + : containment.audit + ? { containment: containment.audit } + : {}, }); return { toolRunId: run.id, output, ...(evidence.reference ? { artifactId: evidence.reference.artifactId } : {}), + ...(containment.resolution + ? { containmentPolicySnapshotId: containment.resolution.snapshot.id } + : {}), + ...(containment.audit?.snapshotArtifactId + ? { containmentSnapshotArtifactId: containment.audit.snapshotArtifactId } + : {}), }; } catch (cause) { const error = redactError(cause, redact); @@ -278,6 +363,7 @@ export function createSecurityActionExecutor(dependencies: SecurityActionExecuti toolRunId: run.id, intent, error, + ...(containment.audit ? { containment: containment.audit } : {}), }); await dependencies.toolRuns.finish(request.projectId, run.id, { status: "failed", @@ -287,10 +373,16 @@ export function createSecurityActionExecutor(dependencies: SecurityActionExecuti ? { artifactIds: [evidence.reference.artifactId], ...(evidence.reference.metadata ?? {}), + ...(containment.audit ? { containment: containment.audit } : {}), } : evidence.error - ? { evidenceCaptureError: evidence.error } - : {}, + ? { + evidenceCaptureError: evidence.error, + ...(containment.audit ? { containment: containment.audit } : {}), + } + : containment.audit + ? { containment: containment.audit } + : {}, }); if (cause instanceof InternalDenial) { throw new SecurityActionDeniedError(cause.message, run.id, cause.reason); @@ -300,6 +392,98 @@ export function createSecurityActionExecutor(dependencies: SecurityActionExecuti }; } +function resolveSecurityActionContainment( + request: SecurityActionExecutionInput, + intent: NormalizedSecurityActionIntent, + startedAt: string, +): { resolution?: ContainmentPolicyResolution; audit?: SecurityActionContainmentAudit } { + if (!request.containment) return {}; + try { + const resolution = resolveContainmentPolicy({ + policy: request.containment.policy, + scope: { + targetIds: intent.targetId ? [intent.targetId] : [], + destinations: intent.targetLocator ? [destinationFromLocator(intent.targetLocator)] : [], + approvalIntent: { + action: request.approvalAction ?? "security-action", + normalized: intent.approvalIntent.normalized, + ...(request.durableApprovalId + ? { durableDecisionId: request.durableApprovalId } + : {}), + }, + }, + now: startedAt, + }); + return { + resolution, + audit: { + mode: request.containment.mode, + policySnapshotId: resolution.snapshot.id, + policyContentHash: resolution.snapshot.contentHash, + }, + }; + } catch (cause) { + const diagnostic = + cause instanceof ContainmentPolicyError + ? cause.diagnostic + : { code: "invalid-policy", message: cause instanceof Error ? cause.message : String(cause) }; + return { + audit: { + mode: request.containment.mode, + diagnostic: { + code: diagnostic.code, + message: diagnostic.message, + ...("field" in diagnostic && diagnostic.field ? { field: diagnostic.field } : {}), + }, + }, + }; + } +} + +function destinationFromLocator(locator: string) { + const url = new URL(locator); + const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("Target locator must resolve to a valid destination port."); + } + return { host: url.hostname, port, protocol: "tcp" as const }; +} + +async function persistContainmentSnapshot( + persist: SecurityActionExecutionDependencies["persistContainmentSnapshot"], + input: Parameters< + NonNullable + >[0], +): Promise { + if (persist) return persist(input); + const artifact = await getArtifactService().createArtifact({ + projectId: input.projectId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetIds: [...input.resolution.snapshot.approvedTargetIds], + projectScoped: input.resolution.snapshot.approvedTargetIds.length === 0, + toolRunId: input.toolRunId, + name: `${input.resolution.snapshot.id.replaceAll(":", "-")}.json`, + kind: "report", + contentType: "application/json", + content: JSON.stringify({ + schemaVersion: "exploit-hunter.containment-policy.v1", + mode: input.mode, + snapshot: input.resolution.snapshot, + runtime: input.resolution.runtime, + evaluation: input.resolution.evaluation, + }), + source: "containment-policy", + indexForRag: false, + agentGenerated: true, + metadata: { + policySnapshotId: input.resolution.snapshot.id, + policyContentHash: input.resolution.snapshot.contentHash, + containmentMode: input.mode, + }, + }); + return { artifactId: artifact.id }; +} + function readArtifactId(value: unknown): string | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const artifactId = (value as Record).artifactId; diff --git a/tests/integration/containment-policy.test.ts b/tests/integration/containment-policy.test.ts new file mode 100644 index 000000000..7203bf296 --- /dev/null +++ b/tests/integration/containment-policy.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { + ContainmentPolicyError, + type ContainmentPolicyInput, + resolveContainmentPolicy, +} from "../../src/server/containment"; + +const approvalIntent = { + action: "target-action", + normalized: "http-probe\u0000https://example.test:443", + durableDecisionId: "approval-1", +}; + +function policy( + overrides: Partial = {}, +): ContainmentPolicyInput { + return { + approvedTargetIds: ["target-b", "target-a", "target-a"], + pinnedDestinations: [ + { host: "API.EXAMPLE.TEST.", port: 8443 }, + { host: "example.test", port: 443, protocol: "tcp" }, + ], + dnsPolicy: { + mode: "pinned", + records: [{ hostname: "EXAMPLE.TEST.", addresses: ["203.0.113.8"] }], + }, + mounts: [ + { + source: "/workspace/project/./src", + target: "/work/src", + access: "read-only", + }, + ], + capabilities: { + allowed: ["NET_RAW", "CHOWN", "CHOWN"], + dropped: ["SYS_ADMIN"], + }, + resourceLimits: { + cpuCount: 1.5, + memoryBytes: 536_870_912, + pids: 128, + maxRuntimeMs: 30_000, + }, + isolationMode: "container", + expiresAt: "2026-08-27T00:00:00.000Z", + approvalIntent, + ...overrides, + }; +} + +describe("containment policy", () => { + it("normalizes one immutable snapshot into matching runtime and eval projections", () => { + const resolved = resolveContainmentPolicy({ + policy: policy(), + scope: { + targetIds: ["target-a"], + destinations: [{ host: "EXAMPLE.TEST", port: 443 }], + approvalIntent, + }, + now: "2026-08-26T12:00:00.000Z", + }); + const reordered = resolveContainmentPolicy({ + policy: policy({ + approvedTargetIds: ["target-a", "target-b"], + capabilities: { allowed: ["CHOWN", "NET_RAW"], dropped: ["SYS_ADMIN"] }, + }), + scope: { + targetIds: ["target-a"], + destinations: [{ host: "example.test.", port: 443, protocol: "tcp" }], + approvalIntent, + }, + now: "2026-08-26T12:00:00.000Z", + }); + + expect(resolved.snapshot.id).toBe(reordered.snapshot.id); + expect(resolved.snapshot.approvedTargetIds).toEqual([ + "target-a", + "target-b", + ]); + expect(resolved.snapshot.pinnedDestinations).toEqual([ + { host: "api.example.test", port: 8443, protocol: "tcp" }, + { host: "example.test", port: 443, protocol: "tcp" }, + ]); + expect(resolved.snapshot.mounts).toEqual([ + { + source: "/workspace/project/src", + target: "/work/src", + access: "read-only", + }, + ]); + expect(resolved.runtime.policySnapshotId).toBe(resolved.snapshot.id); + expect(resolved.evaluation.policySnapshotId).toBe(resolved.snapshot.id); + expect(resolved.runtime.network.dnsPolicy).toEqual( + resolved.snapshot.dnsPolicy, + ); + expect(resolved.evaluation.requestedTargetIds).toEqual(["target-a"]); + expect(Object.isFrozen(resolved)).toBe(true); + expect(Object.isFrozen(resolved.snapshot.pinnedDestinations)).toBe(true); + }); + + it("fails closed with policy identity and expiry diagnostics", () => { + expect(() => + resolveContainmentPolicy({ + policy: policy({ expiresAt: "2026-08-26T12:00:00.000Z" }), + scope: { + targetIds: ["target-a"], + destinations: [{ host: "example.test", port: 443 }], + approvalIntent, + }, + now: "2026-08-26T12:00:00.000Z", + }), + ).toThrowError( + expect.objectContaining({ + name: "ContainmentPolicyError", + diagnostic: expect.objectContaining({ + code: "policy-expired", + field: "expiresAt", + policySnapshotId: expect.stringMatching(/^containment:sha256:/u), + }), + }), + ); + }); + + it("fails closed when execution widens target, destination, or approval scope", () => { + const cases = [ + { + scope: { + targetIds: ["target-outside"], + destinations: [{ host: "example.test", port: 443 }], + approvalIntent, + }, + code: "target-scope-mismatch", + field: "targetIds", + }, + { + scope: { + targetIds: ["target-a"], + destinations: [{ host: "outside.test", port: 443 }], + approvalIntent, + }, + code: "destination-scope-mismatch", + field: "destinations", + }, + { + scope: { + targetIds: ["target-a"], + destinations: [{ host: "example.test", port: 443 }], + approvalIntent: { ...approvalIntent, normalized: "changed-command" }, + }, + code: "approval-intent-mismatch", + field: "approvalIntent", + }, + ] as const; + + for (const testCase of cases) { + try { + resolveContainmentPolicy({ + policy: policy(), + scope: testCase.scope, + now: "2026-08-26T12:00:00.000Z", + }); + expect.fail(`Expected ${testCase.code}`); + } catch (error) { + expect(error).toBeInstanceOf(ContainmentPolicyError); + expect((error as ContainmentPolicyError).diagnostic).toMatchObject({ + code: testCase.code, + field: testCase.field, + policySnapshotId: expect.stringMatching(/^containment:sha256:/u), + }); + } + } + }); +}); diff --git a/tests/integration/security-action-execution.test.ts b/tests/integration/security-action-execution.test.ts index e87678620..eb8774d1e 100644 --- a/tests/integration/security-action-execution.test.ts +++ b/tests/integration/security-action-execution.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { InMemoryDurableDecisionRepository } from "../../src/server/approvals"; import type { ToolRunRecord } from "../../src/server/chat/types"; +import type { ContainmentPolicyInput } from "../../src/server/containment"; import { createSecurityActionExecutor, normalizeSecurityActionIntent, @@ -9,6 +10,33 @@ import { } from "../../src/server/security-actions/execution"; import { normalizeActiveTargetAuthorizationRecord } from "../../src/server/targets/authorization-ledger"; +function containmentPolicy(input: { + normalizedIntent: string; + durableDecisionId?: string; + approvedTargetIds?: string[]; +}): ContainmentPolicyInput { + return { + approvedTargetIds: input.approvedTargetIds ?? ["target-1"], + pinnedDestinations: [{ host: "example.test", port: 443, protocol: "tcp" }], + dnsPolicy: { mode: "system" }, + mounts: [], + capabilities: { allowed: [], dropped: ["ALL"] }, + resourceLimits: { + cpuCount: 1, + memoryBytes: 268_435_456, + pids: 64, + maxRuntimeMs: 2_000, + }, + isolationMode: "container", + expiresAt: "2026-08-23T00:00:00.000Z", + approvalIntent: { + action: "target-action", + normalized: input.normalizedIntent, + ...(input.durableDecisionId ? { durableDecisionId: input.durableDecisionId } : {}), + }, + }; +} + function harness() { let nextRun = 0; const runs = new Map(); @@ -96,6 +124,9 @@ describe("security action execution", () => { const captureEvidence = vi.fn(async ({ toolRunId }: { toolRunId: string }) => ({ artifactId: `artifact-${toolRunId}`, })); + const persistContainmentSnapshot = vi.fn(async ({ resolution }) => ({ + artifactId: `artifact-${resolution.snapshot.id}`, + })); const execute = createSecurityActionExecutor({ toolRuns, approvals, @@ -119,6 +150,7 @@ describe("security action execution", () => { redact: (value) => typeof value === "string" ? value.replaceAll("secret", "[redacted]") : value, captureEvidence, + persistContainmentSnapshot, }); const passive = await execute({ @@ -131,6 +163,14 @@ describe("security action execution", () => { projectId: "project-1", threadId: "thread-1", durableApprovalId: approval.id, + approvalAction: "target-action", + containment: { + mode: "enforce-v1", + policy: containmentPolicy({ + normalizedIntent: gatedIntent.approvalIntent.normalized, + durableDecisionId: approval.id, + }), + }, intent: gatedIntent, execute: async ({ toolRunId }) => ({ toolRunId, body: "secret" }), }); @@ -141,11 +181,118 @@ describe("security action execution", () => { ]); expect((await approvals.getById(approval.id))?.consumedByToolRunId).toBe(gated.toolRunId); expect(gated.artifactId).toBe(`artifact-${gated.toolRunId}`); + expect(gated.containmentPolicySnapshotId).toMatch(/^containment:sha256:/u); + expect(gated.containmentSnapshotArtifactId).toMatch(/^artifact-containment:sha256:/u); + expect(persistContainmentSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + threadId: "thread-1", + toolRunId: gated.toolRunId, + mode: "enforce-v1", + resolution: expect.objectContaining({ + runtime: expect.objectContaining({ isolationMode: "container" }), + evaluation: expect.objectContaining({ requestedTargetIds: ["target-1"] }), + }), + }), + ); expect(captureEvidence).toHaveBeenCalledWith( - expect.objectContaining({ toolRunId: gated.toolRunId }), + expect.objectContaining({ + toolRunId: gated.toolRunId, + containment: expect.objectContaining({ + mode: "enforce-v1", + policySnapshotId: gated.containmentPolicySnapshotId, + snapshotArtifactId: gated.containmentSnapshotArtifactId, + }), + }), ); }); + it("enforces containment mismatches while shadow mode never substitutes for approval", async () => { + const targetCoverage = async () => ({ + covered: true as const, + normalizedLocator: "https://example.test/", + authorizationId: "authorization-1", + targetId: "target-1", + matchedLocator: "https://example.test/", + record: normalizeActiveTargetAuthorizationRecord({ + id: "authorization-1", + projectId: "project-1", + targetId: "target-1", + networkProfile: "approved-targets", + target: { locator: "https://example.test" }, + }), + reason: "covered", + }); + const intent = normalizeSecurityActionIntent({ + capabilityId: "httpProbeTool", + input: { method: "GET", url: "https://example.test/health" }, + targetId: "target-1", + targetLocator: "https://example.test", + networkProfile: "approved-targets", + timeoutMs: 2_000, + }); + + const enforcedHarness = harness(); + const enforcedAction = vi.fn(async () => ({ ok: true })); + const enforce = createSecurityActionExecutor({ + toolRuns: enforcedHarness.toolRuns, + approvals: new InMemoryDurableDecisionRepository(), + authorizeTarget: targetCoverage, + now: () => new Date("2026-08-22T01:00:00.000Z"), + }); + await expect( + enforce({ + projectId: "project-1", + approvalAction: "target-action", + intent, + containment: { + mode: "enforce-v1", + policy: containmentPolicy({ + normalizedIntent: intent.approvalIntent.normalized, + approvedTargetIds: ["different-target"], + }), + }, + execute: enforcedAction, + }), + ).rejects.toMatchObject({ + name: "SecurityActionDeniedError", + reason: "target-scope-mismatch", + }); + expect(enforcedAction).not.toHaveBeenCalled(); + expect(enforcedHarness.finishes[0]?.metadata).toMatchObject({ + containment: { + mode: "enforce-v1", + diagnostic: { code: "target-scope-mismatch" }, + }, + }); + + const shadowHarness = harness(); + const shadowAction = vi.fn(async () => ({ ok: true })); + const shadow = createSecurityActionExecutor({ + toolRuns: shadowHarness.toolRuns, + approvals: new InMemoryDurableDecisionRepository(), + authorizeTarget: targetCoverage, + persistContainmentSnapshot: async () => ({ artifactId: "containment-artifact" }), + now: () => new Date("2026-08-22T01:00:00.000Z"), + }); + await expect( + shadow({ + projectId: "project-1", + approvalAction: "target-action", + intent, + containment: { + mode: "shadow-v1", + policy: containmentPolicy({ normalizedIntent: intent.approvalIntent.normalized }), + }, + execute: shadowAction, + }), + ).rejects.toMatchObject({ + name: "SecurityActionDeniedError", + reason: "no-durable-id", + }); + expect(shadowAction).not.toHaveBeenCalled(); + }); + it("fails closed before execution for mismatched normalized intent and records redacted denial diagnostics", async () => { const { toolRuns, finishes } = harness(); const approvals = new InMemoryDurableDecisionRepository();