diff --git a/CLAUDE.md b/CLAUDE.md index 74f184f..12b12e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index a541c64..290f3b3 100644 --- a/README.md +++ b/README.md @@ -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": "" }, "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 }; }, }, diff --git a/bin/ccc-queue.mjs b/bin/ccc-queue.mjs index d3a39cb..d77211d 100755 --- a/bin/ccc-queue.mjs +++ b/bin/ccc-queue.mjs @@ -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,106 +59,188 @@ 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; - } - const lines = [ - `{bold}Action:{/bold} ${item.action}`, - `{bold}ID:{/bold} ${item.id}`, - `{bold}Submitted by:{/bold} ${item.submitted_by}`, - `{bold}Created:{/bold} ${item.created_at}`, - '', - '{bold}Params:{/bold}', - ]; - for (const [k, v] of Object.entries(item.params ?? {})) { - lines.push(` {cyan-fg}${k}{/cyan-fg}: ${v}`); - } - 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}`); + const lines = [ + `{bold}Action:{/bold} ${item.action}`, + `{bold}ID:{/bold} ${item.id}`, + `{bold}Submitted by:{/bold} ${item.submitted_by}`, + `{bold}Created:{/bold} ${item.created_at}`, + '', + '{bold}Params:{/bold}', + ]; + for (const [k, v] of Object.entries(item.params ?? {})) { + lines.push(` {cyan-fg}${k}{/cyan-fg}: ${v}`); } - list_box.select(Math.min(selected, items.length - 1)); + detail_box.setContent(lines.join('\n')); } - 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); diff --git a/claude-info/architecture.md b/claude-info/architecture.md index 60f529d..25fc9b5 100644 --- a/claude-info/architecture.md +++ b/claude-info/architecture.md @@ -4,14 +4,16 @@ ``` server/ - index.mjs — Express app, routing, policy enforcement - actions.mjs — Action registry (add new actions here) - auth.mjs — HMAC-SHA256 middleware, canApprove 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 + index.mjs — Express app, routing, policy enforcement + actions.mjs — Action registry (add new actions here) + 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 diff --git a/claude-info/contributing.md b/claude-info/contributing.md index 442634c..b297da5 100644 --- a/claude-info/contributing.md +++ b/claude-info/contributing.md @@ -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 diff --git a/claude-info/plan-policy-overrides.md b/claude-info/plan-policy-overrides.md new file mode 100644 index 0000000..e637420 --- /dev/null +++ b/claude-info/plan-policy-overrides.md @@ -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. diff --git a/client/conduit.mjs b/client/conduit.mjs index 3d7078a..2daaaa3 100644 --- a/client/conduit.mjs +++ b/client/conduit.mjs @@ -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 }; } diff --git a/config.example.json b/config.example.json index 9364a26..fd2787f 100644 --- a/config.example.json +++ b/config.example.json @@ -12,6 +12,7 @@ "credentials": "../google-credentials.json", "token": "../google-token.json" }, + "policy_overrides": "policy-overrides.json", "bind": "127.0.0.1", "port": 3015 } diff --git a/package.json b/package.json index 7143fa1..90f7783 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server/actions.mjs b/server/actions.mjs index 331fe8e..106ad73 100644 --- a/server/actions.mjs +++ b/server/actions.mjs @@ -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' }, diff --git a/server/auth.mjs b/server/auth.mjs index 17be4bf..f8b8c97 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -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; +} diff --git a/server/config.mjs b/server/config.mjs index 36deadc..e55684b 100644 --- a/server/config.mjs +++ b/server/config.mjs @@ -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; @@ -59,11 +63,12 @@ export function load_config(file_path) { } return { - users: secrets.users, - smtp: parsed.smtp ?? null, + users: secrets.users, + smtp: parsed.smtp ?? null, mail_perms_path, + policy_overrides_path, google_calendar, - bind: parsed.bind ?? null, - port: parsed.port ?? null, + bind: parsed.bind ?? null, + port: parsed.port ?? null, }; } diff --git a/server/index.mjs b/server/index.mjs index df8da9b..23e6133 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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'}`); diff --git a/server/policy_overrides.mjs b/server/policy_overrides.mjs new file mode 100644 index 0000000..b63ea38 --- /dev/null +++ b/server/policy_overrides.mjs @@ -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 }; +}