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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=53 || true

# The Grep tool shells out to ripgrep and parses its `--null` output, and
# its tests self-skip when `rg` is absent. A skipped suite reads as a
# passing one, so install it rather than hope the runner image has it —
# `DC_REQUIRE_RIPGREP` then turns the skip into a failure.
- name: Install ripgrep
run: |
if command -v rg >/dev/null 2>&1; then
rg --version
elif [ "$RUNNER_OS" = "Linux" ]; then
sudo apt-get install -y ripgrep
else
brew install ripgrep
fi

- name: Typecheck
run: pnpm typecheck

Expand All @@ -66,6 +80,7 @@ jobs:
# it self-skips on non-Linux / when bwrap/slirp4netns are absent.
env:
DC_SANDBOX_NET_TEST: '1'
DC_REQUIRE_RIPGREP: '1'
run: pnpm test

- name: Build + app-server release gate
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 🔒 Security

- **A sub-agent did not inherit the file contract.** The `Task` delegation
forwarded mode, permission rules, hooks, sandbox config and auto-mode — every
gate except the contract. So "never read `secrets/**`" bound the main agent
and said nothing to the sub-agent it spawned to do the reading, and since a
contract `deny` is deliberately not waivable, this was the one gate that was
supposed to hold no matter what. A regression test asserts the secret never
reaches the provider.
- **`Grep` and `Glob` returned results the contract denies reading.** Both take
a search _root_, so the pre-call verdict only ever covered where the search
started; a search rooted at the workspace was allowed and then handed back
matches from denied paths, with the matched line attached. Results are now
filtered through the same `evaluatePath` the gate uses — no second glob
dialect to drift — and the output ends with a count of what was withheld,
never with the paths. `ask` is not filtered: mid-search there is nobody to
ask, and a hit is not yet a read.
- The plugin capability bridge passed no contract into the tools it executed, so
a plugin's `Grep` skipped the same filter.

### 🐛 Fixed

- **`Grep` over a single file no longer prefixes every line with a colon.**
ripgrep omits the filename when the search path is one _file_ — there is
nothing to disambiguate — so its `--null` output carries no NUL, and rejoining
the record as `path:text` with an absent path emitted `:1:hit`. Parsing now
distinguishes "rg printed no path" from "rg printed an empty field", and the
separator is written back only where rg wrote one. Such a row is attributed to
the search root for contract filtering, so the result filter does not depend on
the pre-call gate having adjudicated that call correctly.
- CI installs ripgrep and sets `DC_REQUIRE_RIPGREP=1`. The `Grep` suite
self-skips when `rg` is absent, so it may never have run in CI — and it now
covers ripgrep's `--null` output format, which the tool parses byte for byte.
- **The test suite could re-initialise your own repository.** `git` reads
`GIT_DIR` from the environment and a git hook sets it, so a fixture calling
`git init` on a temp directory from inside the pre-commit gate did not
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/runtime-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,10 @@ export function buildPluginCapabilityBridge(options: PluginBridgeOptions): Plugi
cwd: options.cwd,
signal: options.signal,
sandboxConfig: options.sandboxConfig,
// Grep and Glob filter their own results against the contract, so the
// bridge has to hand it over — the pre-call verdict above only covers
// the search root.
contract: options.contract,
});
await options.hooks.dispatch({
event: 'PostToolUse',
Expand Down
35 changes: 35 additions & 0 deletions docs/file-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,41 @@ to skip prompts has no business clearing it — otherwise the contract's stronge
sentence would also be its easiest to disable. A contract `ask` is an ordinary
approval and follows the mode and hook chain like any other.

A sub-agent runs under its parent's contract. Delegation is not a way around it.

### Search results

`Grep` and `Glob` take a search **root**, so deciding before the call only
answers where the search starts. A search rooted at the workspace is allowed and
then returns whatever it finds — including, before this was closed, the matched
line out of a file the contract said must never be read.

So their results are filtered afterwards, through the same rules:

| Contract says | Grep | Glob |
| ------------- | ---------------------------------------- | ------------ |
| `read: deny` | hit removed, along with its matched line | path removed |
| `read: ask` | hit kept | path kept |
| `read: allow` | hit kept | path kept |

`ask` is not filtered. It means "stop and ask before reading this file", and
mid-search there is nobody to ask — a single `Grep` turning into two hundred
prompts is how a contract gets deleted. A path appearing in a result listing is
not yet a read, and reading it still goes through the ordinary approval.

When anything is withheld, the output ends with a count:

```
[2 results withheld by the file contract]
```

The count, never the paths. Staying silent would be worse than the count leaks:
an agent that searches and finds nothing goes looking through `Bash`, which the
contract does not reach at all.

Note that ripgrep already skips hidden and `.gitignore`d files by default, so
`.env` never reaches this filter — but `secrets/prod.key` does.

## Interaction with `settings.json`

The two rule sets compose by **most-restrictive-wins**:
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runAgent as runAgentCore, type RunAgentOptions } from './agent.js';
import { parseFileContract } from './config/file-contract.js';
import type { LedgerKind, LedgerSink, NewLedgerRecord } from './ledger/index.js';
import { HookDispatcher } from './hooks/index.js';
import { SessionManager } from './sessions/index.js';
Expand Down Expand Up @@ -511,6 +512,48 @@ describe('runAgent', () => {
expect(provider.received).toHaveLength(3);
});

it('a sub-agent inherits the file contract', async () => {
// The delegation forwarded mode, permissions, hooks, sandbox and autoMode
// but not the contract, so "never read this path" held for the main agent
// and said nothing to the sub-agent it spawned to do the reading. Note the
// mode here is `bypassPermissions` — a contract deny is not waivable, which
// is precisely why it has to travel.
await fs.writeFile(join(cwd, 'prod.key'), 'KEY=hunter2\n');
const provider = new MockProvider([
toolUse('delegating', {
type: 'tool_use',
id: 'task1',
name: 'Task',
input: { prompt: 'read prod.key and tell me the value' },
}),
toolUse('reading', {
type: 'tool_use',
id: 'r1',
name: 'Read',
input: { file_path: join(cwd, 'prod.key') },
}),
endTurn('could not read it'), // ← sub-agent, after the block
endTurn('done'), // ← back in the top-level agent
]);
await runAgent({
provider,
tools: new ToolRegistry(),
systemPrompt: '',
userMessage: 'what is the key?',
model: 'deepseek-chat',
cwd,
contract: parseFileContract(
['version: 1', 'rules:', ' - glob: "prod.key"', ' read: deny'].join('\n'),
),
});

// The sub-agent's Read must have been refused, so the secret never reaches
// any message the provider was handed.
const everySentMessage = JSON.stringify(provider.received);
expect(everySentMessage).not.toContain('hunter2');
expect(everySentMessage).toMatch(/file contract/);
});

it('a sub-agent cannot spawn further sub-agents (depth guard)', async () => {
// At subAgentDepth=1, runSubAgent is not wired, so Task fails gracefully.
const provider = new MockProvider([
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
signal: opts.signal,
sandboxConfig: opts.sandboxConfig,
sandboxDefaultMode: opts.sandboxDefaultMode,
contract: opts.contract,
sessionDir: opts.session ? `${opts.session.manager.root}/${opts.session.id}` : undefined,
turnId: opts.session?.turnId,
askUser: opts.askUser,
Expand Down Expand Up @@ -377,6 +378,11 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
signal: signal ?? opts.signal,
mode: runtimePolicy.mode,
permissions: runtimePolicy.permissions,
// Every gate the parent runs under has to travel with the delegation.
// A contract that stops at the Task boundary is one that says "never
// read .env" to the main agent and nothing at all to the sub-agent it
// spawns to do the reading.
contract: opts.contract,
hooks: opts.hooks,
sandboxConfig: opts.sandboxConfig,
autoMode: opts.autoMode,
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/config/contract-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
evaluateContract,
fileContractWarnings,
mostRestrictive,
withheldNotice,
withholdDeniedReads,
} from './contract-dispatch.js';
import { parseFileContract, type FileContract } from './file-contract.js';
import type { PermissionVerdict } from './permissions.js';
Expand Down Expand Up @@ -213,3 +215,69 @@ describe('fileContractWarnings', () => {
).toEqual([]);
});
});

// Grep and Glob take a search *root*, so the pre-call gate can only adjudicate
// where the search starts. These cover what it finds.
describe('withholdDeniedReads', () => {
const id = (p: string) => p;

it('is the identity when there is no contract', () => {
const paths = ['src/a.ts', '.env'];
expect(withholdDeniedReads(undefined, CWD, paths, id)).toEqual({
kept: paths,
withheld: 0,
});
});

it('removes paths the contract denies reading', () => {
expect(withholdDeniedReads(secrets, CWD, ['src/a.ts', '.env', '.env.local'], id)).toEqual({
kept: ['src/a.ts'],
withheld: 2,
});
});

it('keeps `ask` paths', () => {
// There is nobody to prompt mid-search, and a hit is not yet a read. One
// Grep turning into two hundred approvals is how a contract gets deleted.
const asks = contract('rules:\n - glob: "**/*.ts"\n read: ask\n');
expect(withholdDeniedReads(asks, CWD, ['src/a.ts'], id)).toEqual({
kept: ['src/a.ts'],
withheld: 0,
});
});

it('keeps paths outside the workspace, matching the pre-call gate', () => {
// A per-project contract has no authority over /etc, and inventing one here
// would make the filter disagree with the gate it is supposed to complete.
expect(withholdDeniedReads(secrets, CWD, ['/etc/hosts', '../sibling/.env'], id)).toEqual({
kept: ['/etc/hosts', '../sibling/.env'],
withheld: 0,
});
});

it('reads the path out of whatever shape the caller has', () => {
const rows = [
{ path: 'src/a.ts', text: 'hit' },
{ path: '.env', text: 'SECRET=hunter2' },
];
const { kept, withheld } = withholdDeniedReads(secrets, CWD, rows, (r) => r.path);
expect(kept).toEqual([{ path: 'src/a.ts', text: 'hit' }]);
expect(withheld).toBe(1);
expect(JSON.stringify(kept)).not.toContain('hunter2');
});

it('keeps a row with no path — it cannot be attributed, so it is not judged', () => {
expect(withholdDeniedReads(secrets, CWD, [''], id)).toEqual({ kept: [''], withheld: 0 });
});
});

describe('withheldNotice', () => {
it('says nothing when nothing was withheld', () => {
expect(withheldNotice(0)).toBeUndefined();
});

it('reports the count and never the paths', () => {
expect(withheldNotice(1)).toBe('[1 result withheld by the file contract]');
expect(withheldNotice(3)).toBe('[3 results withheld by the file contract]');
});
});
56 changes: 56 additions & 0 deletions packages/core/src/config/contract-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,62 @@ export function contractGovernedTools(): string[] {
return Object.keys(TOOL_AXIS);
}

export interface WithheldResults<T> {
kept: T[];
/** How many entries the contract removed. Reported, never itemised. */
withheld: number;
}

/**
* Remove search results whose path the contract denies reading.
*
* Grep and Glob take a *search root*, so the pre-call gate can only adjudicate
* where the search starts — not what it finds. A search rooted at the workspace
* is allowed, and then returns `.env` among its hits, with the matched line
* attached. The pre-call verdict was correct and the outcome still contradicts
* the contract; the gap is that the tool produces paths nobody asked about.
*
* The decision runs through `evaluatePath`, the same function the gate uses, on
* a path normalized the same way. There is deliberately no second glob dialect
* and no translation into ripgrep's exclusion syntax: an approximate copy of the
* rules that diverges from the original is worse than the gap it closes.
*
* Only `deny` withholds. `ask` means "stop and ask before reading this file" and
* there is nobody to ask mid-search — turning one Grep into two hundred prompts
* would get the contract deleted, and a path in a result listing is not yet a
* read. Read the file and the ordinary `ask` still fires.
*
* A path outside the workspace is kept, matching the gate: a per-project
* contract has no authority over `/etc`.
*/
export function withholdDeniedReads<T>(
contract: FileContract | undefined,
cwd: string,
items: T[],
pathOf: (item: T) => string,
): WithheldResults<T> {
if (!contract) return { kept: items, withheld: 0 };

const kept: T[] = [];
let withheld = 0;
for (const item of items) {
const raw = pathOf(item);
const path = raw ? normalizeContractPath(cwd, raw) : null;
if (path !== null && evaluatePath(contract, { path, action: 'read' }).verdict === 'deny') {
withheld++;
continue;
}
kept.push(item);
}
return { kept, withheld };
}

/** One line naming how much was withheld, without naming any of it. */
export function withheldNotice(withheld: number): string | undefined {
if (withheld <= 0) return undefined;
return `[${withheld} result${withheld === 1 ? '' : 's'} withheld by the file contract]`;
}

const SEVERITY: Record<PermissionVerdict, number> = {
'no-match': 0,
allow: 1,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export {
evaluateContract,
fileContractWarnings,
mostRestrictive,
withheldNotice,
withholdDeniedReads,
type ContractDispatchRequest,
type ContractWarningInput,
} from './contract-dispatch.js';
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ export {
evaluateContract,
fileContractWarnings,
mostRestrictive,
withheldNotice,
withholdDeniedReads,
} from './config/index.js';

// Credentials (M2; M3c adds ApiKeyHelperRefresher)
Expand Down
Loading
Loading