Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions docs/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/config/src/defaults/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export const DEFAULT_CODEDECAY_CONFIG: CodeDecayConfig = {
allowCommands: false,
capabilityPolicy: {
version: CODEDECAY_CAPABILITY_POLICY_VERSION,
allow: []
allow: [],
sandbox: "best-effort"
}
},
llm: {
Expand Down
1 change: 1 addition & 0 deletions packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
CodeDecayCapabilityAllowRule,
CodeDecayCapabilityKind,
CodeDecayCapabilityPolicy,
CodeDecayCapabilitySandboxMode,
CodeDecayCommandToolAdapter,
CodeDecayCommands,
CodeDecayConfig,
Expand Down
30 changes: 26 additions & 4 deletions packages/config/src/normalize/capability-policy.ts
Original file line number Diff line number Diff line change
@@ -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<string>(CODEDECAY_CAPABILITY_KINDS);
const SANDBOX_MODES = new Set<CodeDecayCapabilitySandboxMode>(["off", "best-effort", "required"]);

export function createDefaultCapabilityPolicy(): CodeDecayCapabilityPolicy {
return {
version: CODEDECAY_CAPABILITY_POLICY_VERSION,
allow: []
allow: [],
sandbox: "best-effort"
};
}

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

Expand All @@ -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.`);
Expand Down
3 changes: 2 additions & 1 deletion packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export type {
export type {
CodeDecayCapabilityAllowRule,
CodeDecayCapabilityKind,
CodeDecayCapabilityPolicy
CodeDecayCapabilityPolicy,
CodeDecayCapabilitySandboxMode
} from "./types/capability-policy";
export {
CODEDECAY_CAPABILITY_KINDS,
Expand Down
7 changes: 7 additions & 0 deletions packages/config/src/types/capability-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ 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.
*/
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;
}
5 changes: 4 additions & 1 deletion packages/config/test/capability-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ describe("capability policy config normalization", () => {

expect(loaded.config.safety.capabilityPolicy).toEqual({
version: 1,
allow: []
allow: [],
sandbox: "best-effort"
});
});

Expand All @@ -23,6 +24,7 @@ describe("capability policy config normalization", () => {
" allowCommands: true",
" capabilityPolicy:",
" version: 1",
" sandbox: required",
" allow:",
" - capability: artifact.persist",
" paths:",
Expand All @@ -38,6 +40,7 @@ describe("capability policy config normalization", () => {

expect(loaded.config.safety.capabilityPolicy).toEqual({
version: 1,
sandbox: "required",
allow: [
{
capability: "artifact.persist",
Expand Down
3 changes: 2 additions & 1 deletion packages/config/test/config-defaults-loading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ describe("CodeDecay config defaults and loading", () => {
allowCommands: false,
capabilityPolicy: {
version: 1,
allow: []
allow: [],
sandbox: "best-effort"
}
},
llm: {
Expand Down
3 changes: 2 additions & 1 deletion packages/config/test/fixtures/full-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export const EXPECTED_FULL_CONFIG: CodeDecayConfig = {
allowCommands: true,
capabilityPolicy: {
version: 1,
allow: []
allow: [],
sandbox: "best-effort"
}
},
llm: {
Expand Down
192 changes: 192 additions & 0 deletions packages/execution/src/capability/approvals.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, CapabilityApproval>>();

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<string, CapabilityApproval>();
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();
}
Loading
Loading