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
10 changes: 6 additions & 4 deletions packages/adapters/src/command-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Finding } from "@submuxhq/codedecay-core";
import { runConfiguredCommand, type CommandExecutionResult } from "@submuxhq/codedecay-execution";
import { createSafeCommandPolicy, runConfiguredCommand, type CommandExecutionResult } from "@submuxhq/codedecay-execution";
import type { AdapterContext, AdapterResult, CodeDecayAdapter, CommandAdapterOptions } from "./types";
import { validateCommandAdapterOptions } from "./validation";

Expand All @@ -21,9 +21,11 @@ async function runCommandAdapter(
command: options.command,
cwd: context.rootDir,
timeoutMs: options.timeoutMs ?? context.config.safety.commandTimeoutMs,
safety: {
allowCommands: options.requiresCommandAllowlist ? context.config.safety.allowCommands : true
}
safety: createSafeCommandPolicy({
allowCommands: options.requiresCommandAllowlist ? context.config.safety.allowCommands : true,
capabilityPolicy: context.config.safety.capabilityPolicy
}),
capabilityIntentSource: "user-config"
});

return adapterResultFromExecution(options, result);
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export async function runLoopCommand(
securityScoreThreshold: options.securityScoreThreshold,
agentTimeoutMs: loadedConfig.config.safety.commandTimeoutMs,
commandSafety: {
allowCommands: loadedConfig.config.safety.allowCommands
allowCommands: loadedConfig.config.safety.allowCommands,
capabilityPolicy: loadedConfig.config.safety.capabilityPolicy
},
createRedteamReport: async () =>
await createRedteamReportForCli(rootDir, {
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/product/generated-tests/runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { CodeDecayProductTarget, LoadedCodeDecayConfig } from "@submuxhq/codedecay-config";
import { runConfiguredCommand } from "@submuxhq/codedecay-execution";
import { createSafeCommandPolicy, runConfiguredCommand } from "@submuxhq/codedecay-execution";
import { generatedProductBaseUrl } from "./manifest";
import { elapsed } from "./strings";
import type {
Expand Down Expand Up @@ -99,9 +99,11 @@ export async function runGeneratedProductTests(
env: {
CODEDECAY_PRODUCT_BASE_URL: generatedProductBaseUrl(rootDir, generatedTests)
},
safety: {
allowCommands: loadedConfig.config.safety.allowCommands
}
safety: createSafeCommandPolicy({
allowCommands: loadedConfig.config.safety.allowCommands,
capabilityPolicy: loadedConfig.config.safety.capabilityPolicy
}),
capabilityIntentSource: "user-config"
});
const testSource = readFileSync(join(rootDir, generatedTests.sourcePath), "utf8");
const impactedFiles = dependencies.findImpactedProductFiles(rootDir);
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/product/generated-tests/runner/retry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { LoadedCodeDecayConfig, CodeDecayProductTarget } from "@submuxhq/codedecay-config";
import { runConfiguredCommand } from "@submuxhq/codedecay-execution";
import { createSafeCommandPolicy, runConfiguredCommand } from "@submuxhq/codedecay-execution";
import { generatedProductBaseUrl } from "../manifest";
import type {
ProductGeneratedTestCase,
Expand Down Expand Up @@ -76,9 +76,11 @@ export async function attachGeneratedFailureRetryEvidence(input: {
env: {
CODEDECAY_PRODUCT_BASE_URL: generatedProductBaseUrl(input.rootDir, input.generatedTests)
},
safety: {
allowCommands: input.loadedConfig.config.safety.allowCommands
}
safety: createSafeCommandPolicy({
allowCommands: input.loadedConfig.config.safety.allowCommands,
capabilityPolicy: input.loadedConfig.config.safety.capabilityPolicy
}),
capabilityIntentSource: "user-config"
});
const parsed = parsePlaywrightTestRun({
stdout: execution.stdout,
Expand Down
24 changes: 21 additions & 3 deletions packages/cli/src/product/runtime/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:chil
import type { LoadedCodeDecayConfig } from "@submuxhq/codedecay-config";
import {
checkCommandSafety,
createSafeCommandPolicy,
detectShellSubstitution,
runConfiguredCommand,
type CommandExecutionResult
} from "@submuxhq/codedecay-execution";
Expand All @@ -18,9 +20,11 @@ export async function runProductOneShotCommand(
command,
cwd: rootDir,
timeoutMs,
safety: {
allowCommands: loadedConfig.config.safety.allowCommands
}
safety: createSafeCommandPolicy({
allowCommands: loadedConfig.config.safety.allowCommands,
capabilityPolicy: loadedConfig.config.safety.capabilityPolicy
}),
capabilityIntentSource: "user-config"
});
}

Expand All @@ -42,6 +46,20 @@ export async function startManagedProductProcess(
};
}

const substitution = detectShellSubstitution(command);
if (substitution) {
const message = `Command was blocked by CodeDecay capability policy: command rejected: ${substitution}.`;
return {
command,
status: "blocked",
durationMs: 0,
stdout: "",
stderr: message,
error: message,
blockedReason: `command rejected: ${substitution}`
};
}

const safety = checkCommandSafety(command);
if (!safety.safe) {
const message = `Command was blocked by CodeDecay safety policy: ${safety.reason}.`;
Expand Down
1 change: 1 addition & 0 deletions packages/execution/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { runConfiguredCommand } from "./command";
export { checkCommandSafety } from "./safety";
export { createSafeCommandPolicy } from "./safe-policy";
export {
authorizeCapability,
appendCapabilityAuditEvent,
Expand Down
20 changes: 20 additions & 0 deletions packages/execution/src/safe-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createDefaultCapabilityPolicy } from "./capability";
import type { CapabilityPolicy } from "./capability";
import type { SafeCommandPolicy } from "./types";

export function createSafeCommandPolicy(input: {
allowCommands: boolean;
capabilityPolicy?: CapabilityPolicy | undefined;
allowUnsafeCommands?: boolean | undefined;
}): SafeCommandPolicy {
const policy: SafeCommandPolicy = {
allowCommands: input.allowCommands,
capabilityPolicy: input.capabilityPolicy ?? createDefaultCapabilityPolicy()
};

if (input.allowUnsafeCommands !== undefined) {
policy.allowUnsafeCommands = input.allowUnsafeCommands;
}

return policy;
}
35 changes: 35 additions & 0 deletions packages/execution/test/capability-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
authorizeCapability,
checkPathWithinAllowedRoots,
createDefaultCapabilityPolicy,
createSafeCommandPolicy,
detectShellSubstitution,
fetchWithoutExternalRedirect,
resolveCapabilityAuditPath,
Expand Down Expand Up @@ -175,6 +176,40 @@ describe("capability policy foundation", () => {
expect(allowed.allowed).toBe(true);
});

it("passes loaded capabilityPolicy into configured command execution", async () => {
const root = createTempDir();
const result = await runConfiguredCommand({
command: "node -e \"console.log('policy')\"",
cwd: root,
timeoutMs: 1000,
safety: createSafeCommandPolicy({
allowCommands: true,
capabilityPolicy: {
version: 1,
allow: [{ capability: "command.execute", commands: ["node"] }]
}
})
});

expect(result.status).toBe("passed");

const denied = await runConfiguredCommand({
command: "node -e \"console.log('nope')\"",
cwd: root,
timeoutMs: 1000,
safety: createSafeCommandPolicy({
allowCommands: true,
capabilityPolicy: {
version: 1,
allow: [{ capability: "command.execute", commands: ["pnpm"] }]
}
})
});

expect(denied.status).toBe("blocked");
expect(denied.blockedReason).toContain("not listed in capabilityPolicy.allow commands");
});

it("blocks credentials, metadata hosts, and off-allowlist redirect targets", async () => {
expect(
validateNetworkDestination("http://user:pass@127.0.0.1/health", {
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp/src/execution/safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export function createExecutionSafety(
const notes = [
"This MCP tool never runs arbitrary commands from MCP input.",
"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."
"Command execution also requires safety.allowCommands: true in CodeDecay config.",
"Configured commands also pass through safety.capabilityPolicy (default deny for elevated capabilities).",
"confirmExecution authorizes only this execute_configured_checks call; it does not grant unrelated later capabilities."
];

if (!confirmExecution) {
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/product/safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export function createProductSafety(
allowCommands: loadedConfig.config.safety.allowCommands,
notes: [
...notes,
"Product target startup, browser automation, and generated test execution still obey safety.allowCommands in CodeDecay config.",
"Product target startup, browser automation, and generated test execution still obey safety.allowCommands and safety.capabilityPolicy in CodeDecay config.",
"MCP confirmation authorizes only this product operation; it does not grant unrelated capabilities.",
"No telemetry, cloud execution, LLM calls, or arbitrary MCP-provided commands are used."
]
};
Expand Down
5 changes: 3 additions & 2 deletions packages/tool-adapters/src/agent-process/configured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import { createAgentProcessHarness } from "./harness";

export function createConfiguredAgentProcessHarness(
adapter: CodeDecayAgentProcessToolAdapter,
allowCommands: boolean
safety: { allowCommands: boolean; capabilityPolicy?: import("@submuxhq/codedecay-execution").CapabilityPolicy | undefined }
): ConfiguredToolHarness {
const options: AgentProcessHarnessOptions = {
allowCommands
allowCommands: safety.allowCommands,
capabilityPolicy: safety.capabilityPolicy
};

if (adapter.command !== undefined) {
Expand Down
6 changes: 2 additions & 4 deletions packages/tool-adapters/src/agent-process/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
agentProcessMissingCommandEvidence
} from "./evidence";
import { validateAgentProcessPlan } from "./validation";
import { toolAdapterSafeCommandPolicy } from "../safety";

export async function runAgentProcessPlan(
plan: HarnessPlan,
Expand Down Expand Up @@ -65,10 +66,7 @@ export async function runAgentProcessPlan(
CODEDECAY_AGENT_PROFILE: profile,
CODEDECAY_AGENT_OUTPUT_UNTRUSTED: "1"
},
safety: {
allowCommands: options.allowCommands ?? false,
allowUnsafeCommands: options.allowUnsafeCommands
}
safety: toolAdapterSafeCommandPolicy(options)
});
const durationMs = elapsed(startedAt);
const artifacts = [{ path: bundle.artifactPath, description: "CodeDecay agent task bundle passed to the local agent process." }];
Expand Down
5 changes: 3 additions & 2 deletions packages/tool-adapters/src/coverage/configured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { resolveCoverageDisplayCommand } from "./plan";

export function createConfiguredCoverageHarness(
adapter: CodeDecayCoverageToolAdapter,
allowCommands: boolean
safety: { allowCommands: boolean; capabilityPolicy?: import("@submuxhq/codedecay-execution").CapabilityPolicy | undefined }
): ConfiguredToolHarness {
const options: CoverageHarnessOptions = {
allowCommands
allowCommands: safety.allowCommands,
capabilityPolicy: safety.capabilityPolicy
};

if (adapter.command !== undefined) {
Expand Down
6 changes: 2 additions & 4 deletions packages/tool-adapters/src/coverage/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
DEFAULT_COVERAGE_TIMEOUT_MS
} from "./constants";
import { validateCoveragePlan } from "./validation";
import { toolAdapterSafeCommandPolicy } from "../safety";

export async function runCoveragePlan(
plan: HarnessPlan,
Expand Down Expand Up @@ -134,9 +135,6 @@ async function runCoverageCommand(
cwd: context.cwd,
timeoutMs,
outputLimit: options.outputLimit,
safety: {
allowCommands: options.allowCommands ?? false,
allowUnsafeCommands: options.allowUnsafeCommands
}
safety: toolAdapterSafeCommandPolicy(options)
});
}
18 changes: 11 additions & 7 deletions packages/tool-adapters/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,33 +33,37 @@ export type {

export function createConfiguredToolHarnesses(config: CodeDecayConfig): ConfiguredToolHarness[] {
const configured: ConfiguredToolHarness[] = [];
const safety = {
allowCommands: config.safety.allowCommands,
capabilityPolicy: config.safety.capabilityPolicy
};

if (config.toolAdapters.agentProcess?.enabled) {
configured.push(createConfiguredAgentProcessHarness(config.toolAdapters.agentProcess, config.safety.allowCommands));
configured.push(createConfiguredAgentProcessHarness(config.toolAdapters.agentProcess, safety));
}

if (config.toolAdapters.playwright?.enabled) {
configured.push(createConfiguredPlaywrightHarness(config.toolAdapters.playwright, config.safety.allowCommands));
configured.push(createConfiguredPlaywrightHarness(config.toolAdapters.playwright, safety));
}

if (config.toolAdapters.stryker?.enabled) {
configured.push(createConfiguredStrykerHarness(config.toolAdapters.stryker, config.safety.allowCommands));
configured.push(createConfiguredStrykerHarness(config.toolAdapters.stryker, safety));
}

if (config.toolAdapters.schemathesis?.enabled) {
configured.push(createConfiguredSchemathesisHarness(config.toolAdapters.schemathesis, config.safety.allowCommands));
configured.push(createConfiguredSchemathesisHarness(config.toolAdapters.schemathesis, safety));
}

if (config.toolAdapters.pact?.enabled) {
configured.push(createConfiguredPactHarness(config.toolAdapters.pact, config.safety.allowCommands));
configured.push(createConfiguredPactHarness(config.toolAdapters.pact, safety));
}

if (config.toolAdapters.semgrep?.enabled) {
configured.push(createConfiguredSemgrepHarness(config.toolAdapters.semgrep, config.safety.allowCommands));
configured.push(createConfiguredSemgrepHarness(config.toolAdapters.semgrep, safety));
}

if (config.toolAdapters.coverage?.enabled) {
configured.push(createConfiguredCoverageHarness(config.toolAdapters.coverage, config.safety.allowCommands));
configured.push(createConfiguredCoverageHarness(config.toolAdapters.coverage, safety));
}

return configured;
Expand Down
5 changes: 3 additions & 2 deletions packages/tool-adapters/src/pact/configured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import type { CodeDecayCommandToolAdapter, ConfiguredToolHarness, PactHarnessOpt

export function createConfiguredPactHarness(
adapter: CodeDecayCommandToolAdapter,
allowCommands: boolean
safety: { allowCommands: boolean; capabilityPolicy?: import("@submuxhq/codedecay-execution").CapabilityPolicy | undefined }
): ConfiguredToolHarness {
const command = adapter.command ?? DEFAULT_PACT_COMMAND;
const harnessOptions: PactHarnessOptions & { command: string } = {
command,
allowCommands
allowCommands: safety.allowCommands,
capabilityPolicy: safety.capabilityPolicy
};

if (adapter.timeoutMs !== undefined) {
Expand Down
6 changes: 2 additions & 4 deletions packages/tool-adapters/src/pact/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "../shared/execution";
import { elapsed } from "../shared/values";
import type { PactHarnessOptions } from "../types";
import { toolAdapterSafeCommandPolicy } from "../safety";

export function createPactHarness(options: PactHarnessOptions = {}): CodeDecayHarness {
const command = options.command ?? DEFAULT_PACT_COMMAND;
Expand Down Expand Up @@ -95,10 +96,7 @@ async function runPactPlan(
cwd: context.cwd,
timeoutMs,
outputLimit: options.outputLimit,
safety: {
allowCommands: options.allowCommands ?? false,
allowUnsafeCommands: options.allowUnsafeCommands
}
safety: toolAdapterSafeCommandPolicy(options)
});
const durationMs = elapsed(startedAt);
const evidence = [pactEvidenceFromExecution(execution)];
Expand Down
5 changes: 3 additions & 2 deletions packages/tool-adapters/src/playwright/configured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import type { CodeDecayCommandToolAdapter, ConfiguredToolHarness, PlaywrightHarn

export function createConfiguredPlaywrightHarness(
adapter: CodeDecayCommandToolAdapter,
allowCommands: boolean
safety: { allowCommands: boolean; capabilityPolicy?: import("@submuxhq/codedecay-execution").CapabilityPolicy | undefined }
): ConfiguredToolHarness {
const command = adapter.command ?? DEFAULT_PLAYWRIGHT_COMMAND;
const harnessOptions: PlaywrightHarnessOptions & { command: string } = {
command,
allowCommands
allowCommands: safety.allowCommands,
capabilityPolicy: safety.capabilityPolicy
};

if (adapter.timeoutMs !== undefined) {
Expand Down
Loading
Loading