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:
@@ -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
|
||||
|
||||
|
||||
35
README.md
35
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": "<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 };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
103
claude-info/plan-policy-overrides.md
Normal file
103
claude-info/plan-policy-overrides.md
Normal 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.
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"credentials": "../google-credentials.json",
|
||||
"token": "../google-token.json"
|
||||
},
|
||||
"policy_overrides": "policy-overrides.json",
|
||||
"bind": "127.0.0.1",
|
||||
"port": 3015
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
69
server/policy_overrides.mjs
Normal file
69
server/policy_overrides.mjs
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user