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
37 changes: 37 additions & 0 deletions docs/adr/0001-brain-policy-and-read-only-subagents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ADR-0001: Brain policies with host-owned bounded specialists

- Date: 2026-10-20
- Status: Accepted

## Context

Brain defines project instructions, permission decisions and subagent roles. The coding
host already owns project isolation, worktree mutation, operator approvals and model
routing. Executing Brain-defined roles must preserve those boundaries.

## Decision

Opt in per project using an operator-owned Brain policy outside repositories. Use a fixed
CLI argument vector to query Brain; the model cannot select policies or executables.
Brain can tighten host decisions but never widen them. Bind approvals to both policies,
the active worktree and the exact child definition/task. Recheck policy snapshots before
execution and fail closed on invalid responses or a missing required Brain executable.

The host runs first-stage specialists read-only. Children reuse the parent's model and
endpoint, get only allowed project reads/searches, cannot spawn children, and have explicit
concurrency, step, tool-call, output and time limits. Child usage is recorded through the
host's existing metadata-only writer. Brain itself remains a read-only contract provider.

## Alternatives

- Execute tools inside Brain: duplicates the host's approval and worktree infrastructure.
- Allow role-specific endpoints: could bypass the project's existing egress decision.
- Enable child shell/write access immediately: requires a broader execution and recovery
contract; defer until the read-only lifecycle is established.
- Replace host policy with Brain allow: would loosen existing operator protections.

## Consequences

Existing projects behave as before until opted in. A compatible Brain build is required.
Read-only children cannot implement code changes themselves; the parent performs approved
mutations. Persistent job resume and background scheduling remain outside this increment.
71 changes: 71 additions & 0 deletions docs/brain-agent-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Brain policy at coding-tool dispatch

Agent Control remains the tool executor. Brain's read-only `agent check` command can
add an operator-selected policy to `run_command`, `write_file`, and `edit_file`.

The operator opts a project in by creating
`<workspace>/.local/agent/brain-policies/<project>.yaml` outside product repositories.
Use a Brain build providing `agent check`; `AGENT_BRAIN_BINARY` selects its executable.
Nothing in a model's tool arguments can select an executable or a policy file.

```yaml
default: ask
rules:
- tool: write
path: "private/**"
decision: deny
```

Brain allow never overrides the existing workspace policy. Brain ask requires the
existing single-use approval flow even when the workspace permits automatic execution.
Brain deny, missing executable, malformed output, timeout and invalid policy all prevent
execution. Projects without this optional policy retain their existing behavior.

Approval hashes include both policies and the worktree. Every dispatch evaluates Brain,
and policy snapshots are rechecked before execution. A changed policy invalidates the
request instead of silently using its old decision. These checks complement filesystem
isolation; neither shell-prefix policies nor approval hashes are an operating-system
sandbox.

This integration currently gates coding mutations; task-record mutations retain their
existing workspace gate. Read-only subagent execution is described below when enabled.

## Bounded read-only specialists

For opted-in project chats the host exposes `project_instructions`, `list_subagents`
and `run_subagent`. Definitions are loaded from the active project's or worktree's
`.brain/agents` directory through Brain, including its symlink confinement checks.
Invalid registries fail closed. The project instruction tool returns Brain's rendered
guidance, which explicitly grants no tool permissions.

Children always reuse the parent's model and egress-approved endpoint. Role model
preferences cannot switch endpoints. Each child has a separate prompt, no conversation
history from other projects, and only the intersection of the role's tool list with
`read_file` and `search_code`. Every child read requires Brain allow under the role's
effective policy and must stay inside the project; ask is returned to the parent as a
refusal, not automatically approved. Children receive no delegation, shell, or mutation
tools. Definitions requesting those tools are intentionally narrowed.

Starting a child goes through both policies and the existing approval flow. The approval
input hash includes the role definition, task and role name. Host bounds are two concurrent
children globally, five model steps (or the role's smaller limit), twelve read/search calls,
30 seconds, 2048 output tokens per model step and a 16 KiB returned summary. Request
cancellation propagates to the model call. Completed child token usage is recorded separately
through the parent's normal metadata-only usage writer. No prompt text enters usage logs.

Example role at `.brain/agents/reviewer.md`:

```markdown
---
name: reviewer
description: Review source and identify missing tests
tools: [read, search]
max_turns: 3
---
Read the relevant files and return findings with paths. Do not modify files.
```

For read-only child operation an operator policy can allow `read` and `search` while
leaving `subagent` at ask. The workspace policy must also permit or approve `run_subagent`.
This increment does not provide child write access, recursive delegation, a background job
queue, or persistent resume after a process restart.
11 changes: 10 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,16 @@ app.post(`${AGENT_API}/chat`, async (c) => {
: '')
: SYSTEM_PROMPT,
messages: body.messages,
tools: createChatTools(bridge, scope),
tools: createChatTools(bridge, scope, {
model, signal: c.req.raw.signal,
onUsage: (child) => recordUsage(WORKSPACE_ROOT, {
tool: 'agent-control', endpoint: body.provider ?? config.defaultProvider,
model: body.model ?? config.defaultModel, locality: resolved.locality,
...(scope ? { project: scope.project, projectRoot: join(WORKSPACE_ROOT, scope.folder) } : {}),
start: child.start, end: child.end, finishReason: child.finishReason,
tokens: { input: child.inputTokens, output: child.outputTokens, cacheRead: 0, cacheWrite: 0 },
}),
}),
stopWhen: stepCountIs(5),
});

Expand Down
13 changes: 13 additions & 0 deletions server/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ describe('parseProjectList', () => {
]);
});

it('reads key, client, group, and folder from `ws list` with KIND column', () => {
const output = [
'KEY KIND CLIENT GROUP FOLDER STATUS REMOTE',
'demo-api code acme - projects/acme/demo-api ok git@example.com:acme/demo-api.git',
'demo-web code acme web projects/acme/web/demo-web missing -',
'',
].join('\n');
assert.deepEqual(parseProjectList(output), [
{ key: 'demo-api', client: 'acme', group: '-', folder: 'projects/acme/demo-api' },
{ key: 'demo-web', client: 'acme', group: 'web', folder: 'projects/acme/web/demo-web' },
]);
});

it('ignores colour codes and lines that are not project rows', () => {
assert.deepEqual(parseProjectList('\u001b[1mKEY CLIENT GROUP FOLDER\u001b[0m\nno projects yet\n'), []);
});
Expand Down
16 changes: 12 additions & 4 deletions server/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ export interface ProjectEntry {

const KEY_RE = /^[a-z0-9][a-z0-9_-]*$/;

/** Rows of `ws list` (KEY CLIENT GROUP FOLDER STATUS REMOTE), header and noise skipped. */
/** Rows of `ws list` (with or without KIND column), header and noise skipped. */
export function parseProjectList(output: string): ProjectEntry[] {
// eslint-disable-next-line no-control-regex
const plain = output.replace(/\u001b\[[0-9;]*m/g, '');
return plain.split('\n').flatMap((line) => {
const [key, client, group, folder] = line.trim().split(/\s+/);
if (!key || !KEY_RE.test(key) || !folder?.startsWith('projects/')) return [];
return [{ key, client, group, folder }];
const cols = line.trim().split(/\s+/);
if (cols.length < 4) return [];
const key = cols[0];
if (!key || !KEY_RE.test(key)) return [];
if (cols[3]?.startsWith('projects/')) {
return [{ key, client: cols[1], group: cols[2], folder: cols[3] }];
}
if (cols[4]?.startsWith('projects/')) {
return [{ key, client: cols[2], group: cols[3], folder: cols[4] }];
}
return [];
});
}
66 changes: 66 additions & 0 deletions server/tools/brain-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { evaluateBrainPolicy } from './brain-policy.js';

it('is inactive unless the operator configures a project Brain policy', async () => {
const root=mkdtempSync(join(tmpdir(),'brain-policy-'));
assert.equal((await evaluateBrainPolicy({root,project:'demo',cwd:root,action:'write_file'},{path:'a'})).decision,'inactive');
});
it('fails closed for configured policy when Brain is unavailable', async () => {
const root=mkdtempSync(join(tmpdir(),'brain-policy-'));
mkdirSync(join(root,'.local/agent/brain-policies'),{recursive:true});
writeFileSync(join(root,'.local/agent/brain-policies/demo.yaml'),'default: deny');
const result=await evaluateBrainPolicy({root,project:'demo',cwd:root,action:'write_file'},{path:'a'},'/nonexistent/brain');
assert.equal(result.decision,'deny');
});
it('passes structured command arguments and rejects extra Brain output', async () => {
const root=mkdtempSync(join(tmpdir(),'brain-policy-'));
mkdirSync(join(root,'.local/agent/brain-policies'),{recursive:true});
writeFileSync(join(root,'.local/agent/brain-policies/demo.yaml'),'default: ask');
const fake=join(root,'brain');
writeFileSync(fake,'#!/bin/sh\nprintf \'{"decision":"ask","executed":false}\\n\'\n',{mode:0o700});
const result=await evaluateBrainPolicy({root,project:'demo',cwd:root,action:'run_command'},{command:'echo',args:['$(touch /tmp/never-run)']},fake);
assert.equal(result.decision,'ask');
assert.ok(result.fingerprint);
writeFileSync(fake,'#!/bin/sh\nprintf \'{"decision":"allow","executed":true}\\n\'\n',{mode:0o700});
assert.equal((await evaluateBrainPolicy({root,project:'demo',cwd:root,action:'write_file'},{path:'a'},fake)).decision,'deny');
});

it('Brain ask forces approval even when workspace permits; policy changes void it', async () => {
const { execFileSync } = await import('node:child_process');
const { gate } = await import('./gate.js');
const { decide } = await import('../approvals.js');
const root=mkdtempSync(join(tmpdir(),'brain-gate-'));
execFileSync('git',['init','-q'],{cwd:root});
execFileSync('git',['-c','user.name=Test','-c','user.email=test@example.com','commit','--allow-empty','-qm','init'],{cwd:root});
mkdirSync(join(root,'.local/agent/brain-policies'),{recursive:true});
mkdirSync(join(root,'.local/agent/policies'),{recursive:true});
const policy=join(root,'.local/agent/brain-policies/demo.yaml');
writeFileSync(policy,'default: ask');
writeFileSync(join(root,'.local/agent/policies/demo.json'),JSON.stringify({schema_version:1,default:'change',actions:{}}));
const fake=join(root,'brain');
writeFileSync(fake,'#!/bin/sh\nprintf \'{"decision":"ask","executed":false}\\n\'\n',{mode:0o700});
const previous=process.env.AGENT_BRAIN_BINARY;process.env.AGENT_BRAIN_BINARY=fake;
try {
let calls=0;
const deps={root,project:'demo',cwd:root,action:'write_file'};
const first=await gate(deps,undefined,{path:'a'},async()=>++calls);
assert.ok(!first.ran && 'approvalRequired' in first);
assert.equal(calls,0);
decide(root,'demo',first.requestId,'approved');
writeFileSync(policy,'default: ask\n# changed');
const changed=await gate(deps,first.requestId,{path:'a'},async()=>++calls);
assert.ok(!changed.ran && 'error' in changed);
assert.equal(calls,0);
const second=await gate(deps,undefined,{path:'a'},async()=>++calls);
assert.ok(!second.ran && 'approvalRequired' in second);
decide(root,'demo',second.requestId,'approved');
assert.equal((await gate(deps,second.requestId,{path:'a'},async()=>++calls)).ran,true);
assert.equal(calls,1);
assert.equal((await gate(deps,second.requestId,{path:'a'},async()=>++calls)).ran,false);
assert.equal(calls,1);
} finally {if(previous===undefined)delete process.env.AGENT_BRAIN_BINARY;else process.env.AGENT_BRAIN_BINARY=previous;}
});
61 changes: 61 additions & 0 deletions server/tools/brain-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { execFile } from 'node:child_process';
import { readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { sha256Hex } from '../approvals.js';
import type { GateDeps } from './gate.js';

type BrainDecision = 'inactive' | 'allow' | 'ask' | 'deny';
export interface BrainEvaluation { decision: BrainDecision; fingerprint?: string; error?: string }
const TOOLS: Record<string, string> = { run_command: 'shell', write_file: 'write', edit_file: 'edit', read_file: 'read', search_code: 'search', run_subagent: 'subagent' };

function snapshot(path: string): string | undefined {
try {
const stat = statSync(path);
if (!stat.isFile() || stat.size > 96 * 1024) throw new Error('invalid policy file');
return sha256Hex(readFileSync(path, 'utf8'));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}

/** Operator-selected executable; fixed argv, bounded output, no shell. */
export function runBrain(cwd: string, args: string[], maxBuffer = 128 * 1024, binary = process.env.AGENT_BRAIN_BINARY || 'brain'): Promise<string> {
return new Promise((resolve, reject) => {
execFile(binary, args, { cwd, timeout: 5000, maxBuffer }, (error, stdout) => error ? reject(error) : resolve(stdout));
});
}

/** Brain may only tighten the workspace gate. Its policy is selected by the operator. */
export async function evaluateBrainPolicy(
deps: GateDeps,
input: unknown,
binary = process.env.AGENT_BRAIN_BINARY || 'brain',
): Promise<BrainEvaluation> {
if (!/^[a-z0-9][a-z0-9_-]*$/.test(deps.project)) return { decision: 'deny', error: 'invalid project key' };
// Business task mutations retain their existing gate; Brain governs coding tools.
if (!TOOLS[deps.action]) return { decision: 'inactive' };
const policy = join(deps.root, '.local', 'agent', 'brain-policies', `${deps.project}.yaml`);
try {
const fingerprint = snapshot(policy);
if (!fingerprint) return { decision: 'inactive' };
const args = ['agent', 'check', '--root', deps.cwd, '--policy', policy, '--tool', TOOLS[deps.action]];
if (deps.role) args.push('--role', deps.role);
const data = input as Record<string, unknown>;
if (deps.action === 'run_command') {
if (typeof data.command !== 'string' || (data.args !== undefined && (!Array.isArray(data.args) || !data.args.every((arg) => typeof arg === 'string')))) return { decision: 'deny', error: 'invalid command input' };
const tokens = [data.command, ...((data.args as string[] | undefined) ?? [])];
args.push('--command', tokens.map((token) => /^[a-zA-Z0-9_./:@=+-]+$/.test(token) ? token : JSON.stringify(token)).join(' '));
} else if (deps.action !== 'run_subagent') {
if (typeof data.path !== 'string') return { decision: 'deny', error: 'invalid path input' };
args.push('--path', data.path);
}
const output = await runBrain(deps.cwd, args, 16 * 1024, binary);
const result = JSON.parse(output) as Record<string, unknown>;
if (result.executed !== false || !['allow', 'ask', 'deny'].includes(String(result.decision)) || Object.keys(result).some((key) => !['decision', 'executed'].includes(key))) throw new Error('invalid Brain response');
if (snapshot(policy) !== fingerprint) throw new Error('Brain policy changed during evaluation');
return { decision: result.decision as BrainDecision, fingerprint };
} catch {
return { decision: 'deny', error: 'Brain policy could not be verified; no action executed' };
}
}
8 changes: 5 additions & 3 deletions server/tools/chat-tools.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isAbsolute, join, normalize, sep } from 'node:path';
import { jsonSchema, type Tool } from 'ai';
import { jsonSchema, type Tool, type LanguageModel } from 'ai';
import { subagentTools, type SubagentUsage } from './subagents.js';
import type { WorkspaceBridge } from './workspace.js';
import { runCommand } from './run-command.js';
import { editFileTool, writeFileTool } from './worktree-mutate.js';
Expand Down Expand Up @@ -43,7 +44,7 @@ function withJsonSchema(tools: Record<string, PlainTool>): Record<string, Tool>
}

/** The workspace tools offered to the model in /api/agent/chat. */
export function createChatTools(bridge: WorkspaceBridge, scope?: ChatScope): Record<string, Tool> {
export function createChatTools(bridge: WorkspaceBridge, scope?: ChatScope, child?: { model: LanguageModel; signal?: AbortSignal; onUsage?: (usage: SubagentUsage) => void }): Record<string, Tool> {
const tools: Record<string, PlainTool> = {
list_projects: {
description: 'List all registered projects in the workspace',
Expand Down Expand Up @@ -228,7 +229,8 @@ export function createChatTools(bridge: WorkspaceBridge, scope?: ChatScope): Rec
},
},
};
return withJsonSchema(scope ? bindToProject(tools, scope) : tools);
const scoped = scope ? bindToProject(tools, scope) : tools;
return withJsonSchema({ ...scoped, ...(scope && child ? subagentTools(scope, scoped, child.model, child.signal, child.onUsage) : {}) });
}

/**
Expand Down
22 changes: 18 additions & 4 deletions server/tools/gate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { requestApproval, sha256Hex, verifyAndConsume } from '../approvals.js';
import { headSha } from '../git-head.js';
import { classifyAction, policyHash } from '../policy.js';
import { evaluateBrainPolicy } from './brain-policy.js';

export interface GateDeps {
root: string;
project: string;
/** Where an approval's revision is read from: the coding session's worktree when one is active, else the project's folder in the primary checkout. */
cwd: string;
action: string;
/** Optional child role; can only narrow the operator policy. */
role?: string;
}

export type GateOutcome<T> =
Expand All @@ -30,13 +33,22 @@ export async function gate<T>(
inputForHash: unknown,
run: () => Promise<T>,
): Promise<GateOutcome<T>> {
const brain = await evaluateBrainPolicy(deps, inputForHash);
if (brain.decision === 'deny') return { ran: false, error: brain.error ?? 'Brain policy denies this action' };
const decision = classifyAction(deps.root, deps.project, deps.action);
if (decision.decision === 'incomplete') {
return { ran: false, error: `cannot classify this action: ${decision.reason}` };
}
if (decision.decision === 'allow') {
const workspaceHash = policyHash(deps.root, deps.project);
const guardedRun = async (): Promise<GateOutcome<T>> => {
const current = await evaluateBrainPolicy(deps, inputForHash);
if (current.decision !== brain.decision || current.fingerprint !== brain.fingerprint
|| policyHash(deps.root, deps.project) !== workspaceHash) {
return { ran: false, error: 'tool policy changed before execution; request the action again' };
}
return { ran: true, result: await run() };
}
};
if (decision.decision === 'allow' && brain.decision !== 'ask' && !approvalId) return guardedRun();

const sha = await headSha(deps.cwd);
if (!sha) return { ran: false, error: 'this folder is not a git repository: an approval cannot be bound to a revision' };
Expand All @@ -45,13 +57,15 @@ export async function gate<T>(
headSha: sha,
action: deps.action,
inputHash: sha256Hex(JSON.stringify(inputForHash)),
policyHash: policyHash(deps.root, deps.project),
policyHash: brain.fingerprint
? sha256Hex(JSON.stringify([workspaceHash, brain.fingerprint, deps.cwd]))
: workspaceHash,
};

if (approvalId) {
const verified = verifyAndConsume(deps.root, approvalId, context);
if (!verified.ok) return { ran: false, error: `approval ${approvalId}: ${verified.reason}` };
return { ran: true, result: await run() };
return guardedRun();
}

const request = requestApproval(deps.root, context);
Expand Down
Loading
Loading