Skip to content

Commit 6428400

Browse files
feat(security): capability policy foundation (#690) (#744)
* feat(security): add capability policy foundation for agent workflows Introduce a versioned default-deny capability policy, shell-substitution and path-scope checks, and local capability audit logging on configured command execution. Refs #690. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): clear judge-lab high npm audit findings Override fast-uri and undici to patched releases so the judge-lab CI audit gate passes for the capability-policy PR. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(security): validate network destinations and redirect hops Add allowlisted network destination checks, block credentials and metadata endpoints, and stop product health fetches from following off-allowlist redirects. Refs #690. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 85d832d commit 6428400

33 files changed

Lines changed: 1683 additions & 20 deletions

‎.codedecay/config.example.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ probes:
1818
safety:
1919
commandTimeoutMs: 120000
2020
allowCommands: false
21+
# capabilityPolicy defaults to deny-all elevated capabilities.
22+
# See docs/security/threat-model.md.
2123

2224
llm:
2325
provider: disabled

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ docs/.vitepress/dist/
1717
docs/public/llms.txt
1818
docs/public/llms-full.txt
1919
docs/public/markdown/
20+
.pnpm-store/

‎docs/.vitepress/config.mts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export default defineConfig({
7474
text: "Workflows",
7575
items: [
7676
{ text: "Configuration", link: "/configuration" },
77+
{ text: "Threat Model", link: "/security/threat-model" },
7778
{ text: "Redteam Reports", link: "/redteam" },
7879
{ text: "Task-Scoped Context", link: "/context" },
7980
{ text: "Agent Task Bundles", link: "/agent" },

‎docs/configuration.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ productTesting:
116116
safety:
117117
commandTimeoutMs: 120000
118118
allowCommands: false
119+
# Optional elevated capabilities. Default is deny-all.
120+
# See docs/security/threat-model.md.
121+
# capabilityPolicy:
122+
# version: 1
123+
# allow:
124+
# - capability: artifact.persist
125+
# paths:
126+
# - .codedecay/local
119127

120128
llm:
121129
provider: disabled
@@ -343,6 +351,19 @@ Schemathesis proof checks.
343351
Config files make project commands explicit. CodeDecay should not guess commands
344352
from model output or run arbitrary commands by default.
345353

354+
Capability authorization is additive to `safety.allowCommands`:
355+
356+
- `safety.capabilityPolicy` defaults to deny-all elevated capabilities
357+
(`network`, `secret.env`, `model.call`, `git.mutate`, installs, and so on).
358+
- `safety.allowCommands: true` is trusted user intent for `command.execute` on
359+
configured commands. It does not grant network, secrets, or model calls.
360+
- Agent, memory, MCP, and generated-experiment text alone cannot flip a
361+
capability to allowed.
362+
- Configured command strings with shell substitution (`$(...)`, backticks,
363+
`${...}`, `$ENV`) are rejected before spawn.
364+
- Capability decisions append to `.codedecay/local/capability-audit.jsonl`.
365+
- Threat model: [security/threat-model](./security/threat-model.md).
366+
346367
Current behavior:
347368

348369
- `codedecay analyze` does not require config.

‎docs/security/threat-model.md‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# CodeDecay Threat Model
2+
3+
Status: maintained security baseline for issue
4+
[#690](https://github.com/SubmuxHQ/CodeDecay/issues/690).
5+
6+
This document describes how CodeDecay treats untrusted inputs, which
7+
capabilities are dangerous, and what the default-deny policy is intended to
8+
block. It is not a claim of perfect isolation.
9+
10+
## Assets
11+
12+
| Asset | Why it matters |
13+
| --- | --- |
14+
| Repository source and secrets in the working tree | Primary confidential and integrity target |
15+
| User-configured commands, probes, and product targets | Can mutate the machine or contact services |
16+
| Local memory, skills, ADRs, and docs | Can inject instructions into agent workflows |
17+
| Model/provider credentials and env vars | Exfiltration and unauthorized spend |
18+
| Generated experiment plans and agent patches | Untrusted executable suggestions |
19+
| Capability audit log and reports | Accountability and evidence integrity |
20+
| Git history, worktrees, and CI artifacts | Integrity of base/head comparison |
21+
22+
## Trust zones
23+
24+
```text
25+
Untrusted
26+
repository content, memory, MCP tool results, model output,
27+
agent patches, generated experiments, command stdout/stderr,
28+
telemetry exports
29+
30+
Configured (user-owned, still not fully trusted as code)
31+
.codedecay/config.*, design contracts, explicit CLI flags,
32+
safety.allowCommands, capabilityPolicy.allow entries
33+
34+
Trusted runtime boundary
35+
CodeDecay packages that authorize, audit, and spawn processes
36+
through packages/execution
37+
38+
Out of scope unless explicitly configured
39+
production deploy, production migrate, remote push/merge,
40+
package publish, cluster/infra mutation
41+
```
42+
43+
## Actors
44+
45+
- **Developer / CI operator** — configures policy and intents.
46+
- **User-owned coding agent** — proposes edits and checks; never self-approves
47+
capabilities.
48+
- **External model provider** (Ollama / LiteLLM) — optional, explicit only.
49+
- **Malicious repository author** — plants prompt injection, symlink traps,
50+
or shell-substituted experiment plans.
51+
- **Compromised MCP/tool adapter** — returns forged success or hostile commands.
52+
53+
## Data flows
54+
55+
1. Git diff and file reads → deterministic analysis (`analyzer-js`).
56+
2. Config + memory + skills → redteam / agent packaging (suggestions only).
57+
3. Optional LLM investigation → untrusted hypotheses, never risk scores.
58+
4. `runConfiguredCommand` → capability authorize → safety denylist → spawn →
59+
audit.
60+
5. Reports / MCP / agent bundles → local artifacts; no hidden upload.
61+
62+
## Attack surfaces and abuse cases
63+
64+
| Abuse case | Default control |
65+
| --- | --- |
66+
| Prompt injection asks agent to read secrets and upload them | `secret.env` and `network` denied; untrusted intent sources cannot grant |
67+
| Generated experiment with `$(...)` / backticks | Command rejected before spawn |
68+
| Symlink escape from artifact directory | Canonical path must stay under allowed roots |
69+
| Config or memory text claims `allowCommands: true` without loaded config | Only normalized loaded config + caller intent authorize |
70+
| Agent declares a check “verified” | Agent text is never trusted evidence |
71+
| Destructive `rm -rf`, push, deploy, migrate | Pattern denylist in `checkCommandSafety` |
72+
| Silent model or network use | LLM provider defaults to `disabled`; network capability default-deny |
73+
74+
## Capability policy (version 1)
75+
76+
Capabilities:
77+
78+
`model.call`, `command.execute`, `fs.read`, `fs.write`, `network`,
79+
`secret.env`, `package.install`, `process.start`, `browser`, `database`,
80+
`repo.access`, `git.mutate`, `artifact.persist`.
81+
82+
Defaults deny elevated actions. `safety.allowCommands: true` is explicit
83+
user intent for `command.execute` on configured commands. It does not grant
84+
network, secrets, installs, git mutation, or model calls.
85+
86+
Agent, memory, MCP, and generated-experiment text alone cannot flip a
87+
capability to allowed.
88+
89+
## Residual risks
90+
91+
- OS process isolation / sandboxing is platform-dependent; missing sandbox
92+
features must degrade to blocked or visibly weaker isolation, never silent
93+
full access (follow-up under #690).
94+
- Product health checks and capability `network` authorization validate each
95+
redirect hop against the allowlist and block credentials-in-URL plus common
96+
metadata endpoints. DNS-rebinding defenses for non-literal hostnames are
97+
available via `validateResolvedNetworkDestination` and still need broader
98+
call-site coverage.
99+
- MCP confirmation scopes still need per-tool narrowing beyond the shared
100+
authorize gate.
101+
- Command denylist is heuristic; allowlisted user commands can still be
102+
dangerous if the user authorizes them.
103+
104+
## Audit
105+
106+
Capability decisions append to
107+
`.codedecay/local/capability-audit.jsonl` when a repository cwd is available.
108+
Events cover requested, granted, denied, started, completed, timed-out, and
109+
cancelled phases for attributable review.

‎judge-lab/package-lock.json‎

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎judge-lab/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
},
4141
"overrides": {
4242
"postcss": "8.5.23",
43-
"sharp": "0.35.3"
43+
"sharp": "0.35.3",
44+
"fast-uri": "3.1.5",
45+
"undici": "7.29.0"
4446
},
4547
"type": "module"
4648
}

‎packages/adapters/test/adapters.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,11 @@ function createConfig(input: { allowCommands: boolean }): CodeDecayConfig {
185185
probes: [],
186186
safety: {
187187
commandTimeoutMs: 1000,
188-
allowCommands: input.allowCommands
188+
allowCommands: input.allowCommands,
189+
capabilityPolicy: {
190+
version: 1,
191+
allow: []
192+
}
189193
},
190194
llm: {
191195
provider: "disabled",

‎packages/cli/src/product/runtime/health.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ProductHealthResult } from "../../types";
2+
import { fetchWithoutExternalRedirect } from "@submuxhq/codedecay-execution";
23
import { delay, elapsed } from "./timing";
34

45
export async function pollProductHealth(url: string, timeoutMs: number): Promise<ProductHealthResult> {
@@ -7,6 +8,7 @@ export async function pollProductHealth(url: string, timeoutMs: number): Promise
78
let attempts = 0;
89
let lastStatus: number | undefined;
910
let lastError: string | undefined;
11+
const allowedHosts = hostnameAllowlistForConfiguredUrl(url);
1012

1113
while (Date.now() <= deadline) {
1214
attempts += 1;
@@ -15,9 +17,11 @@ export async function pollProductHealth(url: string, timeoutMs: number): Promise
1517
const timeout = setTimeout(() => controller.abort(), Math.min(2500, remainingMs));
1618

1719
try {
18-
const response = await fetch(url, {
19-
signal: controller.signal
20-
});
20+
const response = await fetchWithoutExternalRedirect(
21+
url,
22+
{ allowedHosts },
23+
{ signal: controller.signal }
24+
);
2125
lastStatus = response.status;
2226

2327
if (response.status >= 200 && response.status < 400) {
@@ -50,3 +54,11 @@ export async function pollProductHealth(url: string, timeoutMs: number): Promise
5054
error: lastError ? `Timed out waiting for a healthy response: ${lastError}` : "Timed out waiting for a healthy response."
5155
};
5256
}
57+
58+
function hostnameAllowlistForConfiguredUrl(url: string): string[] {
59+
try {
60+
return [new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase()];
61+
} catch {
62+
return [];
63+
}
64+
}

‎packages/config/src/clone.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ import type {
77
CodeDecayProductTestingConfig,
88
CodeDecayToolAdapters
99
} from "./types";
10+
import { cloneCapabilityPolicy } from "./normalize/capability-policy";
1011
import { cloneMemoryProviders } from "./normalize/memory-providers";
1112

1213
export function cloneConfig(config: CodeDecayConfig): CodeDecayConfig {
1314
const cloned: CodeDecayConfig = {
1415
version: config.version,
1516
commands: cloneCommands(config.commands),
1617
probes: config.probes.map((probe) => ({ ...probe })),
17-
safety: { ...config.safety },
18+
safety: {
19+
...config.safety,
20+
capabilityPolicy: cloneCapabilityPolicy(config.safety.capabilityPolicy)
21+
},
1822
llm: { ...config.llm },
1923
memoryProviders: cloneMemoryProviders(config.memoryProviders),
2024
toolAdapters: cloneToolAdapters(config.toolAdapters),

0 commit comments

Comments
 (0)