Add runtime policy overrides and TUI policy management view

- server/policy_overrides.mjs: persistent group/action override store
- Group toggle: disabling a group forces all its actions to auto-deny without
  touching individual overrides; re-enabling restores them intact
- Action overrides: cycle auto-accept/queue/auto-deny/clear per action
- Effective policy resolution in index.mjs: group > action override > hardcoded default
- auth.mjs: add check_can_manage_policies (requires canApprove.length > 0)
- Three new endpoints: GET /policies, POST /policies/group/:group, POST /policies/action/:action
- client/conduit.mjs: get_policies, set_group_policy, set_action_policy
- ccc-queue.mjs: policy view (Tab to switch); group/action panel with space/e/←/→
- actions.mjs: group fields on desktop/email/calendar actions; list-actions includes group
- config.example.json: add policy_overrides key
- Bump version to 1.3.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:01:45 +00:00
parent 25891ece7e
commit dd7743501a
14 changed files with 609 additions and 102 deletions

View File

@@ -20,6 +20,7 @@ See [`claude-info/contributing.md`](claude-info/contributing.md) for the checkli
## Active / planned work
- [`claude-info/plan-google-calendar.md`](claude-info/plan-google-calendar.md) — Google Calendar integration ✓ implemented
- [`claude-info/plan-policy-overrides.md`](claude-info/plan-policy-overrides.md) — Runtime policy overrides + TUI policy view ✓ implemented (not yet field tested)
## Running

View File

@@ -57,6 +57,7 @@ Copy [`config.example.json`](config.example.json) to `config.json` and edit it:
{
"secrets": "../secrets.json",
"mail_perms": "mail-perms.json",
"policy_overrides": "policy-overrides.json",
"smtp": {
"host": "smtp.example.com",
"port": 587,
@@ -64,12 +65,16 @@ Copy [`config.example.json`](config.example.json) to `config.json` and edit it:
"auth": { "user": "relay@example.com", "pass": "<password>" },
"from": "agent@example.com"
},
"google_calendar": {
"credentials": "../google-credentials.json",
"token": "../google-token.json"
},
"bind": "127.0.0.1",
"port": 3015
}
```
`secrets` and `mail_perms` paths are resolved relative to the config file. `smtp`, `mail_perms`, `bind`, and `port` are all optional.
All relative paths are resolved from the config file's directory. Optional keys: `smtp`, `mail_perms`, `policy_overrides`, `google_calendar`, `bind`, `port`.
---
@@ -176,6 +181,31 @@ Built-in actions:
Calendar mutation actions (`create`, `update`, `delete`) are `queue` policy — they require human approval in the TUI before executing. Listing is `auto-accept`.
### Runtime policy overrides
The TUI includes a **Policies view** (press `[tab]` to switch from the queue). It lets you toggle groups on/off and override individual action policies without restarting the server.
```
┌─ Groups ──────────────────────┐ ┌─ Actions ──────────────────────────────────┐
│ │ │ │
│ > [●] calendar │ │ auto-accept calendar-list-events │
│ [●] email │ │ queue calendar-create-event │
│ [●] desktop │ │ queue * calendar-update-event │
└───────────────────────────────┘ └────────────────────────────────────────────┘
[space] toggle group [→] edit actions [e] cycle policy [tab] queue view
```
- `[space]` toggles a group on/off. Disabling a group makes all its actions `auto-deny`.
- `[→]` moves focus to the actions panel; `[←]` returns to groups.
- `[e]` cycles an action's policy override: `auto-accept → queue → auto-deny → (clear default) → …`
- A `*` marker means the action has a non-default policy.
**Permission model:** only users with a non-empty `canApprove` list can mutate policies. Agents (with `canApprove: []`) can read but never modify. `canApprove` itself is never runtime-writable — it is the static root of the trust hierarchy.
Group and action overrides are independent. Disabling a group does not erase action-level overrides. Re-enabling a group restores them intact.
State persists to `policy_overrides` in `config.json` (if configured).
### Google Calendar setup
Google Calendar support requires OAuth2 credentials from Google Cloud Console.
@@ -214,7 +244,8 @@ Edit `server/actions.mjs`. Each entry needs:
policy: 'auto-accept', // or 'auto-deny' | 'queue'
handler: ({ foo }, ctx) => {
// ctx = { caller, users, mail_perm_store, exec, mailer_send, calendar }
// ctx is optional — omit the second argument if you don't need it
// ctx is optional — omit if unused
// Add group: 'my-group' to the action def to make it togglable in the TUI
return { result: foo };
},
},

View File

@@ -4,29 +4,53 @@ import { load_client_config } from '../client/config.mjs';
import { create_conduit_client } from '../client/conduit.mjs';
const POLL_INTERVAL = 2000;
const VALID_POLICIES = ['auto-accept', 'queue', 'auto-deny'];
const { username, secret, url } = load_client_config(process.argv);
const client = create_conduit_client(username, secret, url);
const screen = blessed.screen({
smartCSR: true,
title: 'ccc-queue',
});
// ── State ─────────────────────────────────────────────────────────────────────
class App_State {
constructor() {
this.view = 'queue'; // 'queue' | 'policies'
this.queue_items = [];
this.policy_state = null; // { groups, action_overrides, effective }
this.all_actions = []; // from list-actions, includes group field
this.left_focused = true; // in policies view: which panel has focus
this.sel_group = 0;
this.sel_action = 0;
}
known_groups() {
return [...new Set(this.all_actions.map(a => a.group).filter(Boolean))].sort();
}
actions_in_group(group) {
return this.all_actions.filter(a => a.group === group);
}
current_group() {
return this.known_groups()[this.sel_group] ?? null;
}
current_action() {
const group = this.current_group();
if (!group) { return null; }
return this.actions_in_group(group)[this.sel_action] ?? null;
}
}
const state = new App_State();
// ── Layout ────────────────────────────────────────────────────────────────────
const list_box = blessed.list({
top: 0,
left: 0,
width: '40%',
height: '100%-3',
const screen = blessed.screen({ smartCSR: true, title: 'ccc-queue' });
const left_box = blessed.list({
top: 0, left: 0, width: '40%', height: '100%-3',
border: { type: 'line' },
label: ' Pending Actions ',
scrollable: true,
keys: true,
vi: true,
mouse: true,
tags: true,
scrollable: true, keys: true, vi: true, mouse: true, tags: true,
style: {
selected: { bg: 'blue', fg: 'white', bold: true },
border: { fg: 'cyan' },
@@ -35,58 +59,82 @@ const list_box = blessed.list({
});
const detail_box = blessed.box({
top: 0,
left: '40%',
width: '60%',
height: '100%-3',
top: 0, left: '40%', width: '60%', height: '100%-3',
border: { type: 'line' },
label: ' Details ',
scrollable: true,
alwaysScroll: true,
tags: true,
scrollable: true, alwaysScroll: true, tags: true,
style: { border: { fg: 'cyan' }, label: { fg: 'cyan', bold: true } },
});
const actions_list = blessed.list({
top: 0, left: '40%', width: '60%', height: '100%-3',
border: { type: 'line' },
label: ' Actions ',
scrollable: true, keys: true, vi: true, mouse: true, tags: true,
style: {
selected: { bg: 'blue', fg: 'white', bold: true },
border: { fg: 'cyan' },
label: { fg: 'cyan', bold: true },
},
});
const status_bar = blessed.box({
bottom: 0,
left: 0,
width: '100%',
height: 3,
border: { type: 'line' },
tags: true,
bottom: 0, left: 0, width: '100%', height: 3,
border: { type: 'line' }, tags: true,
style: { border: { fg: 'grey' } },
});
screen.append(list_box);
screen.append(left_box);
screen.append(detail_box);
screen.append(actions_list);
screen.append(status_bar);
// ── State ─────────────────────────────────────────────────────────────────────
// ── Rendering ─────────────────────────────────────────────────────────────────
let items = [];
let status_text = '';
function policy_color(policy) {
if (policy === 'auto-deny') { return 'red'; }
if (policy === 'auto-accept') { return 'green'; }
if (policy === 'queue') { return 'yellow'; }
return 'grey';
}
function set_status(text, is_error = false) {
status_text = text;
const color = is_error ? 'red' : 'green';
status_bar.setContent(
` {${color}-fg}${text}{/${color}-fg} ` +
`{grey-fg}[y]{/grey-fg} approve ` +
`{grey-fg}[n]{/grey-fg} deny ` +
`{grey-fg}[r]{/grey-fg} refresh ` +
`{grey-fg}[q]{/grey-fg} quit`
);
const msg_color = is_error ? 'red' : 'green';
let keys;
if (state.view === 'queue') {
keys = `{grey-fg}[y]{/grey-fg} approve {grey-fg}[n]{/grey-fg} deny ` +
`{grey-fg}[r]{/grey-fg} refresh {grey-fg}[tab]{/grey-fg} policies {grey-fg}[q]{/grey-fg} quit`;
} else if (state.left_focused) {
keys = `{grey-fg}[space]{/grey-fg} toggle group {grey-fg}[→]{/grey-fg} edit actions ` +
`{grey-fg}[tab]{/grey-fg} queue {grey-fg}[q]{/grey-fg} quit`;
} else {
keys = `{grey-fg}[e]{/grey-fg} cycle policy {grey-fg}[←]{/grey-fg} groups ` +
`{grey-fg}[tab]{/grey-fg} queue {grey-fg}[q]{/grey-fg} quit`;
}
status_bar.setContent(` {${msg_color}-fg}${text}{/${msg_color}-fg} ${keys}`);
screen.render();
}
function render_detail(item) {
function render_queue() {
left_box.setLabel(' Pending Actions ');
detail_box.show();
actions_list.hide();
const sel = left_box.selected ?? 0;
left_box.clearItems();
if (state.queue_items.length === 0) {
left_box.addItem('{grey-fg}(no pending items){/grey-fg}');
} else {
for (const item of state.queue_items) {
left_box.addItem(`{yellow-fg}${item.id.slice(0, 8)}{/yellow-fg} ${item.action}`);
}
left_box.select(Math.min(sel, state.queue_items.length - 1));
}
const item = state.queue_items[left_box.selected] ?? null;
if (!item) {
detail_box.setContent('{grey-fg}No item selected{/grey-fg}');
return;
}
} else {
const lines = [
`{bold}Action:{/bold} ${item.action}`,
`{bold}ID:{/bold} ${item.id}`,
@@ -101,40 +149,98 @@ function render_detail(item) {
detail_box.setContent(lines.join('\n'));
}
function render_list() {
const selected = list_box.selected ?? 0;
list_box.clearItems();
if (items.length === 0) {
list_box.addItem('{grey-fg}(no pending items){/grey-fg}');
} else {
for (const item of items) {
list_box.addItem(`{yellow-fg}${item.id.slice(0, 8)}{/yellow-fg} ${item.action}`);
}
list_box.select(Math.min(selected, items.length - 1));
}
render_detail(items[list_box.selected] ?? null);
left_box.focus();
screen.render();
}
// ── Polling ───────────────────────────────────────────────────────────────────
function render_policies() {
left_box.setLabel(' Groups ');
detail_box.hide();
actions_list.show();
const groups = state.known_groups();
// Left: groups
left_box.clearItems();
if (groups.length === 0) {
left_box.addItem('{grey-fg}(no groups defined){/grey-fg}');
} else {
for (const g of groups) {
const disabled = state.policy_state?.groups?.[g] ?? false;
const dot = disabled ? '{red-fg}[○]{/red-fg}' : '{green-fg}[●]{/green-fg}';
const label = disabled ? `{grey-fg}${g} (off){/grey-fg}` : g;
left_box.addItem(`${dot} ${label}`);
}
left_box.select(Math.min(state.sel_group, groups.length - 1));
}
// Right: actions in selected group
const group = state.current_group();
const group_disabled = group ? (state.policy_state?.groups?.[group] ?? false) : false;
const acts = group ? state.actions_in_group(group) : [];
actions_list.clearItems();
if (acts.length === 0) {
actions_list.addItem('{grey-fg}(no actions){/grey-fg}');
} else {
for (const a of acts) {
const effective = state.policy_state?.effective?.[a.action] ?? a.policy;
const has_override = state.policy_state?.action_overrides?.[a.action] !== undefined;
const color = group_disabled ? 'grey' : policy_color(effective);
const marker = has_override ? ' {white-fg}*{/white-fg}' : '';
actions_list.addItem(
`{${color}-fg}${effective.padEnd(12)}{/${color}-fg}${marker} ${a.action}`
);
}
actions_list.select(Math.min(state.sel_action, acts.length - 1));
}
// Highlight focused panel
if (state.left_focused) {
left_box.style.border.fg = 'cyan';
actions_list.style.border.fg = 'grey';
left_box.focus();
} else {
left_box.style.border.fg = 'grey';
actions_list.style.border.fg = 'cyan';
actions_list.focus();
}
screen.render();
}
function render() {
if (state.view === 'queue') {
render_queue();
} else {
render_policies();
}
}
// ── Data fetching ─────────────────────────────────────────────────────────────
async function refresh() {
try {
items = await client.get_queue();
render_list();
const [queue_result, policy_result, actions_result] = await Promise.all([
client.get_queue(),
client.get_policies(),
client.list_actions(),
]);
state.queue_items = Array.isArray(queue_result) ? queue_result : [];
state.policy_state = policy_result;
state.all_actions = actions_result?.result ?? [];
render();
set_status(`Last refresh: ${new Date().toLocaleTimeString()}`);
} catch (err) {
set_status(`Refresh failed: ${err.message}`, true);
}
}
// ── Actions ───────────────────────────────────────────────────────────────────
// ── Queue operations ──────────────────────────────────────────────────────────
async function decide(decision) {
const item = items[list_box.selected];
if (!item) {
return;
}
const item = state.queue_items[left_box.selected];
if (!item) { return; }
set_status(`Sending ${decision} for ${item.id.slice(0, 8)}`);
try {
await client.resolve_queue_item(item.id, decision);
@@ -144,22 +250,100 @@ async function decide(decision) {
}
}
// ── Keys ──────────────────────────────────────────────────────────────────────
// ── Policy operations ─────────────────────────────────────────────────────────
async function toggle_group() {
const group = state.current_group();
if (!group) { return; }
const currently_disabled = state.policy_state?.groups?.[group] ?? false;
set_status(`Toggling group '${group}'…`);
try {
await client.set_group_policy(group, !currently_disabled);
await refresh();
} catch (err) {
set_status(`Failed: ${err.message}`, true);
}
}
async function cycle_action_policy() {
const act = state.current_action();
if (!act) { return; }
const current_override = state.policy_state?.action_overrides?.[act.action] ?? null;
// Cycle: auto-accept → queue → auto-deny → null (clear) → auto-accept → …
const next_idx = current_override === null
? 0
: (VALID_POLICIES.indexOf(current_override) + 1) % (VALID_POLICIES.length + 1);
const next = next_idx < VALID_POLICIES.length ? VALID_POLICIES[next_idx] : null;
set_status(`Setting '${act.action}' → ${next ?? '(default)'}`);
try {
await client.set_action_policy(act.action, next);
await refresh();
} catch (err) {
set_status(`Failed: ${err.message}`, true);
}
}
// ── Key bindings ──────────────────────────────────────────────────────────────
screen.key(['q', 'C-c'], () => process.exit(0));
screen.key('r', refresh);
screen.key('y', () => decide('approve'));
screen.key('n', () => decide('deny'));
list_box.on('select item', () => {
render_detail(items[list_box.selected] ?? null);
screen.key('tab', () => {
state.view = state.view === 'queue' ? 'policies' : 'queue';
state.left_focused = true;
render();
set_status(`Switched to ${state.view} view`);
});
// Queue view
screen.key('y', () => { if (state.view === 'queue') { decide('approve'); } });
screen.key('n', () => { if (state.view === 'queue') { decide('deny'); } });
// Policy view
screen.key('space', () => {
if (state.view === 'policies' && state.left_focused) { toggle_group(); }
});
screen.key('e', () => {
if (state.view === 'policies' && !state.left_focused) { cycle_action_policy(); }
});
screen.key('right', () => {
if (state.view === 'policies' && state.left_focused) {
state.left_focused = false;
state.sel_action = 0;
render();
set_status('');
}
});
screen.key('left', () => {
if (state.view === 'policies' && !state.left_focused) {
state.left_focused = true;
render();
set_status('');
}
});
// Sync selection state from list navigation
left_box.on('select item', () => {
if (state.view === 'queue') {
render_queue();
} else {
state.sel_group = left_box.selected ?? 0;
state.sel_action = 0;
render_policies();
}
screen.render();
});
list_box.focus();
actions_list.on('select item', () => {
if (state.view === 'policies') {
state.sel_action = actions_list.selected ?? 0;
screen.render();
}
});
// ── Boot ──────────────────────────────────────────────────────────────────────
left_box.focus();
set_status('Connecting…');
refresh();
const poll = setInterval(refresh, POLL_INTERVAL);

View File

@@ -6,12 +6,14 @@
server/
index.mjs — Express app, routing, policy enforcement
actions.mjs — Action registry (add new actions here)
auth.mjs — HMAC-SHA256 middleware, canApprove checks
auth.mjs — HMAC-SHA256 middleware, canApprove/policy checks
config.mjs — Loads config.json + secrets.json
helpers.mjs — exec(), path resolution, volume mapping
queue.mjs — In-memory queue for policy=queue actions
mail_perms.mjs — Persistent mail permission store (JSON file)
mailer.mjs — Nodemailer wrapper
policy_overrides.mjs — Runtime policy override store (JSON file)
google_calendar.mjs — Google Calendar API wrapper (googleapis)
bin/
ccc-server.mjs — Server entry point
@@ -42,6 +44,11 @@ bin/
mailer_send, // async (to, subject, body) => void
calendar, // Google_Calendar_Client | null — null if not configured
}
// Effective policy resolution (index.mjs, evaluated per request):
// 1. action's group is disabled → auto-deny
// 2. action has individual override → use it
// 3. fall back to hardcoded policy in actions.mjs
```
New modules are instantiated in `index.mjs` and added to `make_ctx()`.
@@ -95,6 +102,7 @@ Edit `CONTAINER_PATH` and `VOLUME_MAPPING` in `helpers.mjs` to match the local s
{ name: 'bar', required: false, type: 'path' },
],
policy: 'auto-accept', // or 'queue' | 'auto-deny'
group: 'my-group', // optional — makes it togglable in the TUI policy view
handler: ({ foo, bar }, ctx) => {
// ctx is optional — omit second arg if unused
return { result: foo };
@@ -102,6 +110,15 @@ Edit `CONTAINER_PATH` and `VOLUME_MAPPING` in `helpers.mjs` to match the local s
},
```
## Permission model
- `canApprove` in `secrets.json` — static root of trust, never runtime-writable
- Queue approve/deny — requires `canApprove` over the submitting user
- Mail permissions — requires `canApprove` over the target user
- **Policy overrides** — requires `canApprove.length > 0` (i.e. any user trusted to approve others)
This precedent means: **to mutate permissions, you must have authority over the affected party.** Agents start with `canApprove: []` so they can never elevate their own access.
## Adding a new injectable service (like mailer or calendar)
1. Create `server/my_service.mjs` exporting a factory function

View File

@@ -36,6 +36,7 @@ Use this when adding any non-trivial feature to CCC. Go through every section
- [ ] Update `claude-info/architecture.md`:
- Add the new service to the ctx shape block
- Note any new files in the file layout
- Update the permission model section if a new permission gate was added
- [ ] Update or add a plan file in `claude-info/` — mark status as **implemented**
- [ ] Update `CLAUDE.md` — mark the plan entry as done

View File

@@ -0,0 +1,103 @@
# Plan: Runtime policy overrides & TUI policy management
Status: **implemented**
## Permission model
**Principle: to mutate permissions, you must have authority over the affected party.**
This mirrors `set-mail-permission`, which requires `canApprove` over the target user. Policy overrides affect all agent users, so the caller must have `canApprove` over at least one other user — making them effectively a trusted human.
Implemented as `check_can_manage_policies(users, caller)` in `server/auth.mjs`:
```js
return (users[caller]?.canApprove?.length ?? 0) > 0;
```
**Why this matters as a precedent:** any future permission-mutating operation in CCC (or derived systems) should require the same gate. Agents start with `canApprove: []` by design, so they can never grant themselves new access. Only humans can change what agents are allowed to do.
**The non-negotiable boundary (from issue #3):** `canApprove` itself is never runtime-writable. It is loaded from `secrets.json` at startup and is the root of the entire trust tree.
---
## Data model
Two independent stores, never entangled:
```json
{
"groups": { "calendar": "disabled" },
"actions": { "calendar-list-events": "queue" }
}
```
**Effective policy resolution** (in order of precedence):
1. Action's group is `"disabled"``auto-deny`
2. Action has an individual override → use it
3. Fall back to hardcoded policy in `actions.mjs`
**Key invariant:** group toggling never touches action overrides. Disabling a group just sets the group flag. Re-enabling it removes the flag — individual overrides snap back intact.
---
## Group membership
Actions in `actions.mjs` carry an optional `group` field:
| Group | Actions |
|---|---|
| `calendar` | all four calendar actions |
| `email` | `send-email` only (permission management actions are not gated by this) |
| `desktop` | `edit-file`, `open-browser`, `open-terminal` |
`list-actions`, `set-mail-permission`, `get-mail-permissions`, and meta-actions have no group.
---
## New files
- `server/policy_overrides.mjs` — load/save store, `effective()`, `set_group()`, `set_action()`, `get_all()`
---
## Changed files
| File | Change |
|---|---|
| `server/auth.mjs` | Add `check_can_manage_policies` |
| `server/config.mjs` | Add `policy_overrides_path` |
| `server/index.mjs` | Wire up store; use effective policy in action dispatch; add `/policies` endpoints |
| `server/actions.mjs` | Add `group` field; update `list-actions` to include group |
| `client/conduit.mjs` | Add `get_policies`, `set_group_policy`, `set_action_policy` |
| `bin/ccc-queue.mjs` | Policy view: group toggles + action policy cycling |
| `config.example.json` | Add `policy_overrides` key |
---
## API endpoints (human-only)
| Method | Path | Auth required | Description |
|---|---|---|---|
| `GET` | `/policies` | any authenticated | Returns full policy state |
| `POST` | `/policies/group/:group` | `canApprove.length > 0` | Toggle group disabled state |
| `POST` | `/policies/action/:action` | `canApprove.length > 0` | Set or clear action override |
---
## TUI: policy view
`[Tab]` switches between queue view and policies view.
```
┌─ Groups ──────────────────────┐ ┌─ Actions ──────────────────────────────────┐
│ │ │ │
│ > [●] calendar │ │ auto-accept calendar-list-events │
│ [●] email │ │ queue calendar-create-event │
│ [●] desktop │ │ queue calendar-update-event │
│ │ │ queue calendar-delete-event │
└───────────────────────────────┘ └────────────────────────────────────────────┘
[space] toggle group [→] edit actions [tab] queue view [q] quit
```
When a group is disabled, actions render greyed with `auto-deny`. `[e]` on the right panel cycles an action's override: `auto-accept → queue → auto-deny → (clear/default) → …`.
Action overrides display a `*` marker when a non-default policy is set.

View File

@@ -39,5 +39,33 @@ export function create_conduit_client(username, secret, base_url = process.env.C
return res.json();
}
return { call_action, list_actions, get_queue, resolve_queue_item };
async function get_policies() {
const res = await fetch(`${base_url}/policies`, {
headers: auth_headers(''),
});
return res.json();
}
async function set_group_policy(group, disabled) {
const body_string = JSON.stringify({ disabled });
const res = await fetch(`${base_url}/policies/group/${encodeURIComponent(group)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...auth_headers(body_string) },
body: body_string,
});
return res.json();
}
// policy = null clears the override
async function set_action_policy(action, policy) {
const body_string = JSON.stringify({ policy });
const res = await fetch(`${base_url}/policies/action/${encodeURIComponent(action)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...auth_headers(body_string) },
body: body_string,
});
return res.json();
}
return { call_action, list_actions, get_queue, resolve_queue_item, get_policies, set_group_policy, set_action_policy };
}

View File

@@ -12,6 +12,7 @@
"credentials": "../google-credentials.json",
"token": "../google-token.json"
},
"policy_overrides": "policy-overrides.json",
"bind": "127.0.0.1",
"port": 3015
}

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-conduit",
"version": "1.2.0",
"version": "1.3.0",
"description": "A supervised action bridge between Claude Code and the host system",
"type": "module",
"scripts": {

View File

@@ -16,6 +16,7 @@ export const actions = {
description: def.description,
params: def.params,
policy: def.policy,
group: def.group ?? null,
}));
},
},
@@ -24,6 +25,7 @@ export const actions = {
description: "Open a file in the editor",
params: [{ name: "filename", required: true, type: "path" }],
policy: "auto-accept",
group: "desktop",
handler: ({ filename }, { exec }) => {
const resolved = resolve_path(filename);
exec('subl3', [resolved]);
@@ -48,6 +50,7 @@ export const actions = {
description: "Open a URL in the web browser",
params: [{ name: "url", required: true, type: "string" }],
policy: "queue",
group: "desktop",
handler: ({ url }, { exec }) => {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
@@ -62,6 +65,7 @@ export const actions = {
description: "Open a terminal in a given directory",
params: [{ name: "path", required: false, type: "path" }],
policy: 'queue',
group: "desktop",
handler: ({ path }, { exec }) => {
const resolved = resolve_path(path ?? 'workspace');
exec('konsole', ['--workdir', resolved, '-e', 'bash']);
@@ -78,6 +82,7 @@ export const actions = {
{ name: 'topic', required: true, type: 'string' },
],
policy: 'auto-accept',
group: 'email',
handler: async ({ to, subject, body, topic }, { caller, mail_perm_store, mailer_send }) => {
if (!mail_perm_store.check(caller, to, topic)) {
throw new Error(`Mail permission denied: ${caller}${to} [${topic}]`);
@@ -123,6 +128,7 @@ export const actions = {
'calendar-list-events': {
description: 'List upcoming calendar events',
group: 'calendar',
params: [
{ name: 'calendar_id', required: false, type: 'string' },
{ name: 'time_min', required: false, type: 'string' },
@@ -139,6 +145,7 @@ export const actions = {
'calendar-create-event': {
description: 'Create a new calendar event',
group: 'calendar',
params: [
{ name: 'summary', required: true, type: 'string' },
{ name: 'start', required: true, type: 'string' },
@@ -156,6 +163,7 @@ export const actions = {
'calendar-update-event': {
description: 'Update fields on an existing calendar event',
group: 'calendar',
params: [
{ name: 'event_id', required: true, type: 'string' },
{ name: 'calendar_id', required: false, type: 'string' },
@@ -174,6 +182,7 @@ export const actions = {
'calendar-delete-event': {
description: 'Delete a calendar event',
group: 'calendar',
params: [
{ name: 'event_id', required: true, type: 'string' },
{ name: 'calendar_id', required: false, type: 'string' },

View File

@@ -44,3 +44,11 @@ export function check_can_approve(users, requester_name, submitter_name) {
}
return requester.canApprove.includes(submitter_name);
}
// A user can manage policy overrides if they have canApprove authority over
// at least one other user. This ensures only trusted humans (never agents)
// can change what agents are allowed to do. canApprove itself is never
// runtime-writable — it is loaded from secrets.json at startup.
export function check_can_manage_policies(users, caller) {
return (users[caller]?.canApprove?.length ?? 0) > 0;
}

View File

@@ -46,6 +46,10 @@ export function load_config(file_path) {
? resolve(config_dir, parsed.mail_perms)
: null;
const policy_overrides_path = parsed.policy_overrides
? resolve(config_dir, parsed.policy_overrides)
: null;
let google_calendar = null;
if (parsed.google_calendar) {
const { credentials, token } = parsed.google_calendar;
@@ -62,6 +66,7 @@ export function load_config(file_path) {
users: secrets.users,
smtp: parsed.smtp ?? null,
mail_perms_path,
policy_overrides_path,
google_calendar,
bind: parsed.bind ?? null,
port: parsed.port ?? null,

View File

@@ -2,10 +2,11 @@ import express from 'express';
import { actions } from './actions.mjs';
import { enqueue, get_entry, list_pending, resolve } from './queue.mjs';
import { load_config } from './config.mjs';
import { create_auth_middleware, check_can_approve } from './auth.mjs';
import { create_auth_middleware, check_can_approve, check_can_manage_policies } from './auth.mjs';
import { create_mailer } from './mailer.mjs';
import { exec as real_exec } from './helpers.mjs';
import { load_mail_perms } from './mail_perms.mjs';
import { load_policy_overrides } from './policy_overrides.mjs';
import { create_calendar_client } from './google_calendar.mjs';
function get_arg(argv, flag) {
@@ -30,7 +31,7 @@ try {
process.exit(1);
}
const { users, smtp, mail_perms_path, google_calendar: gcal_cfg } = cfg;
const { users, smtp, mail_perms_path, policy_overrides_path, google_calendar: gcal_cfg } = cfg;
const PORT = process.env.CONDUIT_PORT || cfg.port || 3015;
const BIND = process.env.CONDUIT_BIND || cfg.bind || '127.0.0.1';
@@ -42,6 +43,18 @@ try {
process.exit(1);
}
let policy_store;
try {
policy_store = load_policy_overrides(policy_overrides_path);
} catch (err) {
console.error(`Fatal: ${err.message}`);
process.exit(1);
}
if (!policy_overrides_path) {
console.warn('Warning: policy_overrides not set in config; policy overrides will not persist across restarts');
}
if (!mail_perms_path) {
console.warn('Warning: mail_perms not set in config; mail permissions will not persist across restarts');
}
@@ -116,13 +129,15 @@ app.post('/action', async (req, res) => {
return res.status(400).json({ error: 'Invalid params', details: errors });
}
if (def.policy === 'auto-deny') {
const effective_policy = policy_store.effective(action, def.policy, def.group);
if (effective_policy === 'auto-deny') {
return res.status(403).json({ status: 'denied', reason: 'Policy: auto-deny' });
}
const ctx = make_ctx(req.conduit_user);
if (def.policy === 'auto-accept') {
if (effective_policy === 'auto-accept') {
try {
const result = await run_action(def, params, ctx);
return res.json({ status: 'accepted', result });
@@ -131,7 +146,7 @@ app.post('/action', async (req, res) => {
}
}
if (def.policy === 'queue') {
if (effective_policy === 'queue') {
const id = enqueue(action, params, req.conduit_user);
return res.status(202).json({ status: 'queued', id });
}
@@ -186,6 +201,41 @@ app.post('/queue/:id/deny', (req, res) => {
res.json({ status: 'denied' });
});
// GET /policies — full policy state (any authenticated user)
app.get('/policies', (req, res) => {
res.json(policy_store.get_all(actions));
});
// POST /policies/group/:group — toggle group disabled state (human-only)
app.post('/policies/group/:group', (req, res) => {
if (!check_can_manage_policies(users, req.conduit_user)) {
return res.status(403).json({ error: 'Not authorized to manage policies' });
}
const { disabled } = req.body ?? {};
if (typeof disabled !== 'boolean') {
return res.status(400).json({ error: "Body must have boolean 'disabled' field" });
}
policy_store.set_group(req.params.group, disabled);
res.json({ status: 'ok', group: req.params.group, disabled });
});
// POST /policies/action/:action — set or clear an action policy override (human-only)
app.post('/policies/action/:action', (req, res) => {
if (!check_can_manage_policies(users, req.conduit_user)) {
return res.status(403).json({ error: 'Not authorized to manage policies' });
}
const { policy } = req.body ?? {};
const valid = ['auto-accept', 'queue', 'auto-deny', null];
if (!valid.includes(policy)) {
return res.status(400).json({ error: `policy must be one of: ${valid.slice(0, -1).join(', ')}, or null` });
}
if (!actions[req.params.action]) {
return res.status(404).json({ error: `Unknown action: ${req.params.action}` });
}
policy_store.set_action(req.params.action, policy);
res.json({ status: 'ok', action: req.params.action, policy });
});
app.listen(PORT, BIND, () => {
console.log(`claude-code-conduit server running on ${BIND}:${PORT}`);
console.log(`Workspace root: ${process.env.CONDUIT_ROOT || '/workspace'}`);

View File

@@ -0,0 +1,69 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
// Manages runtime policy overrides for groups and individual actions.
// Groups and action overrides are independent: disabling a group does not
// touch action-level overrides. Re-enabling a group restores them intact.
export function load_policy_overrides(file_path) {
let groups = {}; // { group_name: 'disabled' }
let overrides = {}; // { action_name: policy }
if (file_path && existsSync(file_path)) {
try {
const parsed = JSON.parse(readFileSync(file_path, 'utf8'));
groups = parsed.groups ?? {};
overrides = parsed.overrides ?? {};
} catch (err) {
throw new Error(`Cannot load policy overrides from ${file_path}: ${err.message}`);
}
}
function write() {
if (!file_path) { return; }
writeFileSync(file_path, JSON.stringify({ groups, overrides }, null, '\t') + '\n', 'utf8');
}
// Resolve effective policy for an action at request time.
// Precedence: group disabled > action override > hardcoded default
function effective(action_name, default_policy, group_name) {
if (group_name && groups[group_name] === 'disabled') { return 'auto-deny'; }
return overrides[action_name] ?? default_policy;
}
function set_group(group_name, disabled) {
if (disabled) {
groups[group_name] = 'disabled';
} else {
delete groups[group_name];
}
write();
}
// policy = null clears the override (falls back to hardcoded default)
function set_action(action_name, policy) {
if (policy === null) {
delete overrides[action_name];
} else {
overrides[action_name] = policy;
}
write();
}
// Returns full policy state suitable for the TUI and /policies endpoint.
function get_all(action_defs) {
const known_groups = [...new Set(
Object.values(action_defs).map(d => d.group).filter(Boolean)
)].sort();
return {
groups: Object.fromEntries(known_groups.map(g => [g, groups[g] === 'disabled'])),
action_overrides: { ...overrides },
effective: Object.fromEntries(
Object.entries(action_defs).map(([name, def]) => [
name, effective(name, def.policy, def.group)
])
),
};
}
return { effective, set_group, set_action, get_all };
}