openvibers/openvibe.actor Commits
HO1, universal approvals: every consequential act has a kind, and the person may allow one for an agent (#331)
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++-- phase: expand++-- HO1 (universal approvals, the trust spine): every consequential act has a kind, so the person reads one plain++-- phrase for it on the approval page, in the Action Center and on the agent's own page. The kinds widen; every old++-- row stays valid and keeps its kind (codes.task, actor.action).++--++-- The request now also carries the exact TARGET it would touch ("publish to openvibe.blog", "run git status on++-- dev_x") and the always-ask categories it fell in, so the decision can be scoped and the floor can be checked++-- later without re-reading the tool's input.++--++-- "Always allow this for this agent" (agents/allows.js) is one row per (person, agent, kind, target): a standing++-- permission the person may revoke. It never covers the floor (money, a password, a deletion, a new recipient):++-- that is checked in levels.js and in agents/approvals.js, not here.++CREATE TABLE IF NOT EXISTS actor_approval_allows (++ id text COLLATE "C" PRIMARY KEY, -- alw_<ULID>++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ agent_id text COLLATE "C" NOT NULL, -- agt_… the permission is for++ kind text COLLATE "C" NOT NULL, -- the kind of act it covers++ target text COLLATE "C" NOT NULL, -- the exact target it is scoped to++ created_at text COLLATE "C" NOT NULL,++ revoked_at text COLLATE "C" -- set when the person takes it back++);++CREATE UNIQUE INDEX IF NOT EXISTS actor_approval_allows_scope++ ON actor_approval_allows (owner, agent_id, kind, target) WHERE revoked_at IS NULL;++CREATE INDEX IF NOT EXISTS actor_approval_allows_owner++ ON actor_approval_allows (owner, agent_id, created_at DESC);++++ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS target text COLLATE "C";++ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS categories jsonb;++ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS decision_scope text COLLATE "C";++ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS decided_by text COLLATE "C";++ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS allow_id text COLLATE "C";++++ALTER TABLE actor_approvals DROP CONSTRAINT IF EXISTS actor_approvals_kind_check;++ALTER TABLE actor_approvals ADD CONSTRAINT actor_approvals_kind_check CHECK (kind IN (++ 'codes.task', 'actor.action', 'computer.run', 'computer.write', 'computer.input', 'computer.browser',++ 'computer.desk', 'robot.move', 'space', 'publish', 'post', 'send', 'install', 'routine', 'money'));@@@ -7,6 +7,7 @@*/const PUBLIC_ORIGIN = 'https://openvibe.actor';++const kinds = require('../agents/kinds');const OPEN = ['queued', 'running', 'verifying'];// Actor's own word → the Action Center bucket. A task whose newest event is a "Needs you:" line is waiting, though@@@ -117,18 +118,23 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =};}--function approvalItem(row) {++function approvalItem(row, { agentName = null, thread = null } = {}) {const bucket = bucketFor(row.state);const payload = parse(row.payload) || {};++ // The kind in plain words and the thread it came from (plan HO1): a raw agent id says nothing to a person,++ // so the agent's own name is used, and the kind reads as a phrase. A request with no thread of its own++ // (an old row) keeps its own words.++ const detail = (thread ? plain(thread, 200) : null) || (payload.task ? plain(payload.task, 200) : null);++ const about = [kinds.label(row.kind), detail].filter(Boolean).join(' · ');return {service: 'actor',id: row.id,kind: 'approval',title: plain(row.summary || row.id, 160) || row.id,-- summary: payload.task ? plain(payload.task, 400) : null,++ summary: about ? plain(about, 400) : null,bucket,state: row.state,-- agent: row.agent_id ? plain(row.agent_id, 80) : null,++ agent: agentName ? plain(agentName, 80) : null,cost_usd: null,steps: null,error: row.state === 'expired' ? 'This approval expired before it was decided.' : null,@@@ -1,19 +1,20 @@'use strict';/**-- * Which acting tool steps must see the approval gate first (plan §12 A-2). The gate itself is the++ * Which acting tool steps must see the approval gate first (plan §12 A-2, plan HO1). The gate itself is the* engine's: levels.classify + levels.decide, then a row on /me/approvals. This module only says what-- * a tool call is — its act ('edit' or 'act') and an always-ask category when the call names one — and-- * keeps a secret out of the approval card and the payload.++ * a tool call is — its kind (agents/kinds.js), its act ('edit' or 'act'), an always-ask category when the call++ * names one, the exact target it would touch — and keeps a secret out of the approval card and the payload.** A tool that only reads or writes Actor's own working state (a file or chart of the task, a memory* note) returns null: reading the agent's own files never asks. The generic OpenVibe.Tools call is-- * gated only when its input falls in an always-ask category, so a DNS lookup stays a lookup.++ * gated only when the tool it runs is a consequential one, so a DNS lookup stays a lookup.*-- * stepFor(name, input) → { act, category, summary, payload, recipientKnown? } | null++ * stepFor(name, input) → { act, kind, category?, summary, target, payload, recipientKnown? } | null*/const levels = require('./levels');++const kinds = require('./kinds');const SECRET_KEYS = new Set(['password', 'passwd', 'passcode', 'secret', 'token', 'api_key', 'apikey', 'apiKey', 'access_token', 'refresh_token', 'private_key', 'card_number', 'cvv', 'cvc']);const DELETE_CMD = /^(?:rm|rmdir|del|erase|shred|truncate|unlink|srm|rd)$/i;@@@ -45,16 +46,66 @@ function deletes(argv) {return parts.some((a, i) => DELETE_CMD.test(a) || (i > 0 && /\b(?:rm|del|erase|shred|truncate|unlink)\b/i.test(a)));}++// HO1: a standing permission is matched on the step's target string, so a target names the step exactly: argv words++// are quoted when they hold anything but plain characters (two different commands never read the same), nothing is++// cut and no whitespace is folded. A step that cannot be named within allows.MAX_TARGET has no target, and so can++// never be always allowed.++const word = (w) => (/^[A-Za-z0-9_@%+=:,.\/-]+$/.test(w) ? w : JSON.stringify(w));++const raw = (v) => (typeof v === 'string' ? v : '');++const machineWord = (a) => (raw(a.machine) && a.machine !== 'any' ? word(a.machine) : 'your computer');++const MAX_TARGET = 200;++const exactTarget = (t) => (typeof t === 'string' && t.length > 0 && t.length <= MAX_TARGET && !/[\u0000-\u001f\u007f]/.test(t) ? t : null);++function runTarget(a) {++ if (!Array.isArray(a.argv) || !a.argv.length || !a.argv.every((x) => typeof x === 'string')) return null;++ return exactTarget(`${a.argv.map(word).join(' ')}${raw(a.cwd) ? ` in ${word(a.cwd)}` : ''} on ${machineWord(a)}`);++}++// What repeats and when, both, exactly: allowing a schedule must not allow whatever task rides on it.++function routineTarget(a) {++ const what = raw(a.task) || raw(a.request);++ const when = raw(a.cron) ? `cron ${word(a.cron)}${raw(a.timezone) ? ` ${word(a.timezone)}` : ''}`++ : (a.event && raw(a.event.pattern) ? `when ${word(a.event.pattern)}` : '');++ return what && when ? exactTarget(`${JSON.stringify(what)} ${when}`) : null;++}++/** The target of a coding task in one repository (the engine's own Codes gate uses it too). */++const repoTarget = (repo) => (raw(repo) ? exactTarget(`repo ${word(repo)}`) : null);++// Where a post or a publish lands, when the tool's input names it: the target is the tool and that place.++const DESTINATION_KEYS = ['site', 'host', 'url', 'channel', 'room', 'space_id', 'blog', 'board', 'community'];++function destinationOf(input) {++ const o = input && typeof input === 'object' && !Array.isArray(input) ? input : {};++ for (const k of DESTINATION_KEYS) if (typeof o[k] === 'string' && o[k]) return `${k} ${word(o[k])}`;++ return null;++}++++// The kinds an OpenVibe.Tools tool may be, by its id. The first match wins, so the order is the order of++// the trust: an install is an install, even when its name also says "post".++const TOOL_KINDS = [++ [/^(install|installer|connector|connect|skill|skills)$/, 'install'],++ [/^(pay|payment|payments|purchase|buy|checkout|charge|refund|transfer|wallet|vibes|vibe|balance|invoice|subscription|money|spend|topup)$/, 'money'],++ [/^(publish|publisher)$/, 'publish'],++ [/^(send|sender|email|mail|message|dm|notify|sms|telegram|discord|whatsapp)$/, 'send'],++ [/^(post|comment|reply|share)$/, 'post'],++];++++/** The kind of an OpenVibe.Tools tool by its id, or null when the tool is a plain lookup. */++function toolKind(tool) {++ const words = String(tool || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);++ for (const [re, kind] of TOOL_KINDS) if (words.some((w) => re.test(w))) return kind;++ return null;++}++function stepFor(name, input) {const a = input && typeof input === 'object' && !Array.isArray(input) ? input : {};switch (name) {// ── A person's computer (the old runtime and the modular tools share these names) ──case 'computer_run': {const argv = commandOf(a.argv);++ const line = argv.join(' ');return {act: 'act',++ kind: 'computer.run',...(deletes(argv) ? { category: 'delete' } : {}),-- summary: `Run ${quoted(argv.join(' '), 220) || 'a program'} on your computer`,++ summary: `Run ${quoted(line, 220) || 'a program'} on your computer`,++ target: runTarget(a),payload: safeInput({ machine: a.machine, argv, cwd: a.cwd, desk: a.desk }),};}@@@ -62,7 +113,9 @@ function stepFor(name, input) {const path = text(a.path, 300);return {act: 'edit',++ kind: 'computer.write',summary: `Write ${quoted(path, 220) || 'a file'} on your computer (${text(a.mode, 20) || 'create'})`,++ target: raw(a.path) ? exactTarget(`${word(raw(a.mode) || 'create')} ${word(a.path)} on ${machineWord(a)}`) : null,payload: safeInput({ machine: a.machine, path, mode: a.mode, text: a.text }, { removeText: true }),};}@@@ -71,8 +124,11 @@ function stepFor(name, input) {const password = PASSWORD_FIELD.test(text(a.keys, 80));return {act: 'act',++ kind: 'computer.input',...(password ? { category: 'password' } : {}),summary: action === 'type' ? 'Type on your computer' : `Use ${action || 'the mouse or keyboard'} on your computer`,++ // Typing and clicking reach anything on the screen, a terminal included: never always allowed.++ target: null,payload: safeInput({ machine: a.machine, action, x: a.x, y: a.y, keys: a.keys, text: a.text }, { removeText: action === 'type' }),};}@@@ -87,8 +143,11 @@ function stepFor(name, input) {: `${text(a.action, 20) || 'Use'} the browser on your computer`;return {act: 'act',++ kind: 'computer.browser',...(password ? { category: 'password' } : {}),summary,++ // Only opening one exact address may be always allowed: a click or a fill acts on whatever page is open.++ target: action === 'open' && raw(a.url) ? exactTarget(`open ${word(a.url)} on ${machineWord(a)}`) : null,payload: safeInput({ machine: a.machine, action, url: a.url, ref: a.ref, selector: a.selector, text: a.text, key: a.key, desk: a.desk }, { removeText: action === 'fill' }),};}@@@ -97,45 +156,79 @@ function stepFor(name, input) {if (action === 'status') return null;return {act: 'act',++ kind: 'computer.desk',summary: `${action === 'start' ? 'Start' : 'Stop'} your agent's desk${a.machine && a.machine !== 'any' ? ` on ${text(a.machine, 80)}` : ''}`,++ target: exactTarget(`${action === 'start' ? 'start' : 'stop'} the desk on ${machineWord(a)}`),payload: safeInput({ machine: a.machine, action, kind: a.kind, width: a.width, height: a.height }),};}case 'robot_steps':return {act: 'act',++ kind: 'robot.move',summary: `Move your robot ${text(a.robot, 80)}: ${quoted(commandOf(a.steps).join(', '), 220)}`,++ target: raw(a.robot) ? exactTarget(`robot ${word(a.robot)}`) : null,payload: safeInput({ robot: a.robot, steps: a.steps }),};// ── OpenVibe.Space: the person's own spaces ──case 'space_create':-- return { act: 'act', summary: `Create a space${a.name ? ` ${quoted(a.name, 80)}` : ''} on OpenVibe.Space`, payload: safeInput({ name: a.name, description: a.description }) };++ return {++ act: 'act',++ kind: 'space',++ summary: `Create a space${a.name ? ` ${quoted(a.name, 80)}` : ''} on OpenVibe.Space`,++ target: raw(a.name) ? exactTarget(`new space ${word(a.name)}`) : null,++ payload: safeInput({ name: a.name, description: a.description }),++ };case 'space_files_write':return {act: 'edit',++ kind: 'space',summary: `Write ${Array.isArray(a.files) ? Math.min(a.files.length, 20) : 0} file(s) in your space ${text(a.space_id, 64)}`,++ target: raw(a.space_id) ? exactTarget(`files of space ${word(a.space_id)}`) : null,payload: safeInput({ space_id: a.space_id, files: a.files }),};case 'space_publish':-- return { act: 'act', summary: `Publish your space ${text(a.space_id, 64)} on the open web`, payload: safeInput({ space_id: a.space_id }) };++ return {++ act: 'act',++ kind: 'publish',++ summary: `Publish your space ${text(a.space_id, 64)} on the open web`,++ target: raw(a.space_id) ? exactTarget(`publish space ${word(a.space_id)}`) : null,++ payload: safeInput({ space_id: a.space_id }),++ };// ── Coding on the person's machine ──case 'codes_task': {const repo = text(a.repo, 300);return {act: 'edit',++ kind: 'codes.task',summary: `Start a coding task on your machine${repo ? ` in ${repo}` : ''}`,++ target: repoTarget(a.repo),payload: safeInput({ instruction: a.instruction, repo }),};}-- // ── A generic OpenVibe.Tools tool: a lookup stays a lookup, a floor category asks ──++ // ── A generic OpenVibe.Tools tool: a lookup stays a lookup, a consequential one asks ──case 'run_tool': {-- const summary = `Run the OpenVibe.Tools tool ${text(a.tool, 80)}`;++ const tool = text(a.tool, 80);++ const summary = `Run the OpenVibe.Tools tool ${tool}`;++ const kind = toolKind(tool);const categories = levels.classify({ summary, payload: a.input });-- if (!categories.length) return null;-- return { act: 'act', category: categories[0], summary, payload: safeInput(a.input) };++ if (!kind) {++ // A tool whose name says nothing may still be a payment or a deletion: the floor decides.++ if (!categories.length) return null;++ return { act: 'act', kind: 'actor.action', category: categories[0], summary, target: null, payload: safeInput(a.input) };++ }++ // Spending is the floor whatever the words say. A post or a publish may be allowed for one tool and one++ // place its input names; a send or an install never (kinds.NEVER_ALWAYS), so they carry no target.++ const floorCategory = kind === 'money' ? 'money' : categories[0];++ const dest = (kind === 'post' || kind === 'publish') ? destinationOf(a.input) : null;++ return {++ act: 'act', kind, ...(floorCategory ? { category: floorCategory } : {}), summary,++ target: dest && raw(a.tool) ? exactTarget(`${word(a.tool)} to ${dest}`) : null,++ payload: safeInput(a.input),++ };}// ── A routine: it keeps running after this turn, so the card says what and when in plain words ──@@@ -145,7 +238,10 @@ function stepFor(name, input) {: (a.event && a.event.pattern ? `when ${text(a.event.pattern, 80)} happens` : '');return {act: 'act',++ kind: 'routine',summary: `Set up a task that repeats${when ? ` (${when})` : ''}: ${quoted(what, 160) || 'the one you asked for'}`,++ // What repeats and when, both: allowing a schedule must not allow whatever task rides on it.++ target: routineTarget(a),payload: safeInput({ request: a.request, task: a.task, cron: a.cron, timezone: a.timezone, event: a.event, agent_id: a.agent_id }),};}@@@ -160,8 +256,14 @@ function stepFor(name, input) {// A connector (MCP/A2A) tool this Actor does not know: its acts:true says it may change something.default:-- return { act: 'act', summary: `Use ${text(name, 80)}`, payload: safeInput(a) };++ return {++ act: 'act',++ kind: 'actor.action',++ summary: `Use ${text(name, 80)}`,++ target: exactTarget(`tool ${word(String(name || ''))}`),++ payload: safeInput(a),++ };}}--module.exports = { stepFor, deletes, safeInput, SECRET_KEYS, PASSWORD_FIELD };++module.exports = { stepFor, deletes, safeInput, SECRET_KEYS, PASSWORD_FIELD, toolKind, TOOL_KINDS, repoTarget, runTarget };nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++++/**++ * "Always allow this for this agent" (plan HO1): a standing permission the person gives for one exact kind of++ * step, scoped by the kind and by the target it would touch ("publish to openvibe.blog", "run git status on++ * dev_x"). It never covers the always-ask floor: money, a password, a deletion and a new recipient ask every++ * time, whatever the person allowed before (levels.js).++ *++ * A rule is a row, not a flag: the person may revoke one, and the agent's page lists them.++ *++ * list(s, owner, agentId) → the active rules of one agent++ * listAll(s, owner) → every active rule of the person++ * grant(s, owner, agentId, kind, target) → the rule (made or found again)++ * revoke(s, owner, id) → the rule, revoked++ * matches(rules, kind, target) → true when a rule covers exactly this step++ */++++const kinds = require('./kinds');++++const ID = /^alw_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_TARGET = 200;++++// A rule is matched on its target string, so the string must be the step exactly: never cut and never with its++// whitespace folded (a cut target would also match a different, longer step that starts the same way). A target++// that is too long, empty or carries a control character cannot be allowed at all (agents/actions.js builds them).++const exact = (v) => (typeof v === 'string' && v.length > 0 && v.length <= MAX_TARGET && !/[\u0000-\u001f\u007f]/.test(v) ? v : null);++++/** The active rules of one agent (newest first). */++async function list(s, owner, agentId) {++ if (!agentId) return [];++ return await s.db.many(`SELECT * FROM actor_approval_allows++ WHERE owner = $1 AND agent_id = $2 AND revoked_at IS NULL ORDER BY created_at DESC`, [owner, agentId]);++}++++/** Every active rule of the person (all their agents). */++async function listAll(s, owner) {++ return await s.db.many(`SELECT * FROM actor_approval_allows++ WHERE owner = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 200`, [owner]);++}++++/** Make the rule, or find the one that is already there. One rule per (owner, agent, kind, target). */++async function grant(s, owner, agentId, kind, target) {++ if (!kinds.allowable(kind)) return { error: 'This kind of step always asks, so it can never be always allowed.' };++ const scope = exact(target);++ if (!scope) return { error: 'This step cannot be named exactly enough to allow it always.' };++ const existing = await s.db.maybe(`SELECT * FROM actor_approval_allows++ WHERE owner = $1 AND agent_id = $2 AND kind = $3 AND target = $4 AND revoked_at IS NULL`, [owner, agentId, kind, scope]);++ if (existing) return { row: existing, made: false };++ const now = s.iso();++ const row = await s.db.one(`INSERT INTO actor_approval_allows (id, owner, agent_id, kind, target, created_at)++ VALUES ($1, $2, $3, $4, $5, $6)++ ON CONFLICT DO NOTHING++ RETURNING *`, [s.newId('alw'), owner, agentId, kind, scope, now]);++ if (row) return { row, made: true };++ // The row was made and revoked in the same moment: the partial index lets it live again.++ const again = await s.db.maybe(`SELECT * FROM actor_approval_allows++ WHERE owner = $1 AND agent_id = $2 AND kind = $3 AND target = $4 AND revoked_at IS NULL`, [owner, agentId, kind, scope]);++ return { row: again, made: false };++}++++/** One rule of the person, by its id. */++async function get(s, owner, id) {++ if (!ID.test(String(id || ''))) return null;++ return await s.db.maybe('SELECT * FROM actor_approval_allows WHERE id = $1 AND owner = $2', [id, owner]);++}++++/** Revoke one rule of the person. */++async function revoke(s, owner, id) {++ if (!ID.test(String(id || ''))) return null;++ return await s.db.maybe(`UPDATE actor_approval_allows SET revoked_at = $3++ WHERE id = $1 AND owner = $2 AND revoked_at IS NULL RETURNING *`, [id, owner, s.iso()]);++}++++/** Does a rule cover exactly this step? The kind and the target must both match. */++function matches(rules, kind, target) {++ if (!Array.isArray(rules) || !rules.length || !kinds.allowable(kind)) return false;++ const scope = exact(target);++ if (!scope) return false;++ return rules.some((r) => r && r.kind === kind && r.target === scope);++}++++module.exports = { ID, MAX_TARGET, exact, list, listAll, get, grant, revoke, matches };@@@ -1,33 +1,46 @@'use strict';/**-- * The approval inbox (plan T17: "an approval inbox for consequential actions"). A person's own agent runs while they-- * are away (a schedule, a watch): when its work would act outside Actor on their behalf — today, a coding task on their-- * own machine through OpenVibe.Codes, or one of its acting tools (a send, a purchase, a deletion, a password field, a-- * computer_run, a browser action, a Space publish) — the run asks instead of acting. The request waits on /me/approvals;-- * approving a codes.task starts that work as the person's own task (they are present now), declining or letting it-- * expire (7 days) runs nothing. An actor.action approval records the person's yes for a retry: the run that asked had-- * already stopped, so nothing is replayed on its own. A person's direct task never asks: they are the one asking.++ * The approval inbox (plan T17: "an approval inbox for consequential actions"; plan HO1: universal approvals).++ * A person's own agent runs while they are away (a schedule, a watch): when its work would act outside Actor on++ * their behalf — a coding task on their own machine through OpenVibe.Codes, or any of its acting tools (a send, a++ * purchase, a deletion, a password field, a computer_run, a browser action, a Space publish) — the run asks instead++ * of acting. The request waits on /me/approvals; approving a codes.task starts that work as the person's own task++ * (they are present now), declining or letting it expire (7 days) runs nothing. An actor.action approval records the++ * person's yes for a retry: the run that asked had already stopped, so nothing is replayed on its own. A person's++ * direct task never asks: they are the one asking.*-- * request(s, { owner, agentId, taskId, kind, summary, payload }) → the row (pending)++ * Every request says what KIND of act it is (agents/kinds.js), in plain words, and which exact TARGET it would touch.++ * The person may approve it once, or approve it and say "always allow this for this agent" (agents/allows.js): a++ * standing permission for that kind and that target, revocable from the agent's page. The always-ask floor (money, a++ * password, a deletion, a new recipient) is never covered by such a permission: it asks every time.++ *++ * request(s, { owner, agentId, taskId, kind, summary, payload, target, categories }) → the row (pending)* list(s, owner) / get(s, owner, id) → rows, pending past their expiry marked expired-- * decide(ctx, owner, id, 'approve' | 'decline') → { row, task? } | { error, status }++ * decide(ctx, owner, id, 'approve' | 'approve_always' | 'decline') → { row, task? } | { error, status }* KINDS, EXPIRES_DAYS*/const { createTask } = require('../tasks/create');++const levels = require('./levels');++const kinds = require('./kinds');++const allows = require('./allows');--const KINDS = ['codes.task', 'actor.action'];++const KINDS = kinds.KINDS;const EXPIRES_DAYS = 7;const ID = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;--async function request(s, { owner, agentId = null, taskId = null, kind, summary, payload }) {++/** The categories of a step, as a list of the floor's words (money, password, delete, new_recipient). */++const categoryList = (value) => (Array.isArray(value) ? value.filter((c) => levels.ALWAYS_ASK.includes(c)) : []);++++async function request(s, { owner, agentId = null, taskId = null, kind, summary, payload, target = null, categories = null }) {if (!KINDS.includes(kind)) throw new Error(`unknown approval kind ${kind}`);const id = s.newId('apr');const now = s.iso();const expires = new Date(Date.parse(now) + EXPIRES_DAYS * 86400_000).toISOString();-- return await s.db.one(`INSERT INTO actor_approvals (id, owner, agent_id, task_id, kind, summary, payload, state, created_at, expires_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, 'pending', $8, $9) RETURNING *`,-- [id, owner, agentId, taskId, kind, String(summary).slice(0, 500), JSON.stringify(payload), now, expires]);++ return await s.db.one(`INSERT INTO actor_approvals (id, owner, agent_id, task_id, kind, summary, payload, target, categories, state, created_at, expires_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9::jsonb, 'pending', $10, $11) RETURNING *`,++ [id, owner, agentId, taskId, kind, String(summary).slice(0, 500), JSON.stringify(payload), allows.exact(target),++ JSON.stringify(categoryList(categories)), now, expires]);}/** Pending requests past their expiry become expired as they are read. */@@@ -51,9 +64,28 @@ async function pendingCount(s, owner) {return Number(await s.db.value("SELECT count(*) FROM actor_approvals WHERE owner = $1 AND state = 'pending'", [owner]));}--/** The person decides. Approving a codes.task starts it as their own task, routed to the codes agent.-- * With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON-- * Action Center route); the HTML page keeps its 409. */++/** The floor categories of a row: what it stored, or what its own words say (an old row has no column). */++function floorOf(row) {++ let stored = [];++ try {++ stored = categoryList(typeof row.categories === 'string' ? JSON.parse(row.categories || '[]') : row.categories);++ } catch { stored = []; }++ let payload = null;++ try { payload = typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : row.payload; } catch { payload = null; }++ // What the request stored, what its words say, and a command that deletes by its argv (rm, shred …).++ const argvDeletes = payload && Array.isArray(payload.argv) && require('./actions').deletes(payload.argv);++ const floor = new Set([...stored, ...levels.classify({ summary: row.summary, payload }), ...(argvDeletes ? ['delete'] : [])]);++ if (row.kind === 'money') floor.add('money');++ return [...floor];++}++++/**++ * The person decides. Approving a codes.task starts it as their own task, routed to the codes agent.++ * `approve_always` is approving plus a standing permission for this exact kind and target of this agent; it is++ * refused for a floor step (money, a password, a deletion, a new recipient), which asks every time.++ * With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON++ * Action Center route); the HTML page keeps its 409.++ */async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {const { s, config, engine, webhooks } = ctx;const row = await get(s, owner, id);@@@ -63,23 +95,39 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {return { error: `This request is already ${row.state}.`, status: 409 };}if (decision === 'decline') {-- const done = await s.db.one("UPDATE actor_approvals SET state = 'declined', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);++ const done = await s.db.one("UPDATE actor_approvals SET state = 'declined', decided_at = $2, decided_by = $3 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso(), owner]);return { row: done };}-- if (decision !== 'approve') return { error: 'Approve or decline.', status: 422 };-- // An action had already stopped when it asked: a yes is consent for the person to run it again, never a replay-- // of a call whose arguments only exist in the old run's context.-- if (row.kind === 'actor.action') {-- const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);-- return { row: done, note: 'Approved. The run that asked had already stopped; run it again to let the agent go on.' };++ if (decision !== 'approve' && decision !== 'approve_always') return { error: 'Approve or decline.', status: 422 };++ const always = decision === 'approve_always';++ if (always) {++ const floored = floorOf(row);++ if (floored.length) {++ return { error: `This step always asks (${floored.join(', ')}), so it can never be always allowed.`, status: 422 };++ }++ if (!row.agent_id) return { error: 'This request has no agent to allow it for.', status: 422 };++ if (!kinds.allowable(row.kind)) return { error: 'This kind of step always asks, so it can never be always allowed.', status: 422 };++ if (!row.target) return { error: 'This step has no target to allow.', status: 422 };++ }++ // A coding task starts as the person's own task. Every other kind had already stopped when it asked: a yes is++ // consent for the person to run it again, never a replay of a call whose arguments only exist in the old run.++ if (row.kind !== 'codes.task') {++ const allow = always ? await allows.grant(s, owner, row.agent_id, row.kind, row.target) : { row: null };++ if (always && !allow.row) return { error: allow.error || 'This step could not be allowed.', status: 422 };++ const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, decided_by = $3, decision_scope = $4, allow_id = $5 WHERE id = $1 AND state = 'pending' RETURNING *",++ [id, s.iso(), owner, always ? 'always' : 'once', allow.row ? allow.row.id : null]);++ return { row: done, note: 'Approved. The run that asked had already stopped; run it again to let the agent go on.', allow: allow.row ? allow.row.id : null };}const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;// The person just approved this exact work, so the thread starts at full access: the engine's approval gate must not// ask again. The always-ask floor still applies (the text may still be a deletion or a payment).const out = await createTask({ s, config, engine, webhooks, principal: { requester: owner, via: null, project: null }, body: { task: payload.task, agent: 'codes', mode: 'balanced', level: 'full' } });if (!out.task) return { error: out.detail || 'The task could not start.', status: out.status || 422 };-- const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, result_task_id = $3 WHERE id = $1 RETURNING *", [id, s.iso(), out.task.id]);-- return { row: done, task: out.task };++ const allow = always ? await allows.grant(s, owner, row.agent_id, row.kind, row.target) : { row: null };++ if (always && !allow.row) return { error: allow.error || 'This step could not be allowed.', status: 422 };++ const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, decided_by = $3, decision_scope = $4, allow_id = $5, result_task_id = $6 WHERE id = $1 RETURNING *",++ [id, s.iso(), owner, always ? 'always' : 'once', allow.row ? allow.row.id : null, out.task.id]);++ return { row: done, task: out.task, allow: allow.row ? allow.row.id : null };}--module.exports = { request, list, get, pendingCount, decide, KINDS, EXPIRES_DAYS };++module.exports = { request, list, get, pendingCount, decide, floorOf, KINDS, EXPIRES_DAYS };@@@ -26,7 +26,9 @@ async function lately(s, requester, { approvals = [], agents = [], limit = 10 }for (const a of approvals) {events.push({ at: a.created_at, kind: 'asked', state: 'pending', text: `Asked for your approval: ${a.summary}`, href: `/me/approvals/${a.id}` });if (a.decided_at && DECIDED[a.state]) {-- events.push({ at: a.decided_at, kind: 'decided', state: a.state, text: `${DECIDED[a.state]}: ${a.summary}`, href: `/me/approvals/${a.id}` });++ // Once or always: the person can see which they chose (plan HO1).++ const how = a.state === 'approved' ? (a.decision_scope === 'always' ? ' (and allowed it always for this agent)' : ' (once)') : '';++ events.push({ at: a.decided_at, kind: 'decided', state: a.state, text: `${DECIDED[a.state]}${how}: ${a.summary}`, href: `/me/approvals/${a.id}` });}}return events.sort((x, y) => (x.at < y.at ? 1 : x.at > y.at ? -1 : 0)).slice(0, limit);nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++++/**++ * The kinds of act an approval can be about (plan HO1, "universal approvals": the trust spine). One list,++ * so every acting tool says which kind it is (agents/actions.js) and the person reads one plain phrase for++ * it — on the approval page, in the Action Center and in the agent's own page.++ *++ * The always-ask floor (money, a password, a deletion, a new recipient) is a different thing and lives in++ * levels.js: it is a property of what the step says, not of its kind. A kind is what the act IS; a floor++ * category is what the act would TOUCH. A floor step can never be always-allowed, whatever its kind.++ */++++const KINDS = [++ 'codes.task', // a coding task on the person's own machine++ 'actor.action', // a connected tool (an MCP server or another agent) that acts++ 'computer.run', // run a program on the person's computer++ 'computer.write', // write a file on the person's computer++ 'computer.input', // use the mouse or the keyboard on the person's computer++ 'computer.browser',// open, fill or click in the person's browser++ 'computer.desk', // start or stop the agent's desk on a machine++ 'robot.move', // move the person's robot++ 'space', // change the person's own OpenVibe.Space (make one, write its files)++ 'publish', // publish something for the public to see++ 'post', // post on an OpenVibe site++ 'send', // send an email, a message or a file to someone++ 'install', // install a skill or connect a connector++ 'routine', // set up a task that keeps running++ 'money', // spend the person's money++];++++// The plain words a person reads for each kind. Never an id, never a code: this is the sentence on the card.++const LABELS = {++ 'codes.task': 'a coding task on your machine',++ 'actor.action': 'an action by a connected tool',++ 'computer.run': 'running a program on your computer',++ 'computer.write': 'writing a file on your computer',++ 'computer.input': 'using your mouse or keyboard',++ 'computer.browser': 'acting in your browser',++ 'computer.desk': "starting or stopping your agent's desk",++ 'robot.move': 'moving your robot',++ 'space': 'changing your own OpenVibe.Space',++ 'publish': 'publishing for others to see',++ 'post': 'posting on an OpenVibe site',++ 'send': 'sending something to someone',++ 'install': 'installing a skill or connecting a connector',++ 'routine': 'setting up a task that keeps running',++ 'money': 'spending your money',++};++++// Kinds a person can never "always allow", whatever the target: spending money is the floor itself; typing reaches++// anything a terminal can run (so one rule would cover every command); a send's recipient and an install's code are++// not in the step's name, so no target can say exactly what was allowed.++const NEVER_ALWAYS = ['money', 'computer.input', 'send', 'install'];++++const isKind = (kind) => KINDS.includes(String(kind || ''));++const allowable = (kind) => isKind(kind) && !NEVER_ALWAYS.includes(String(kind));++++/** The plain phrase for a kind; an unknown kind reads as an action, never as an empty card. */++const label = (kind) => LABELS[String(kind || '')] || 'an action';++++/** The phrase with the step's own target: "publishing for others to see · openvibe.blog". */++const phrase = (kind, target) => {++ const what = label(kind);++ const where = String(target || '').trim();++ return where ? `${what} · ${where}` : what;++};++++module.exports = { KINDS, LABELS, NEVER_ALWAYS, isKind, allowable, label, phrase };@@@ -10,7 +10,11 @@** LEVELS, RANK, maxLevel(...)* classify({ summary, payload, category, recipientKnown }) → ['money' | 'password' | 'delete' | 'new_recipient']-- * decide({ level, categories, act, models, summary, payload, signal }) → { ask, floor?, reason, reviewer? }++ * decide({ level, categories, act, models, summary, payload, signal, allow }) → { ask, floor?, reason, reviewer? }++ *++ * `allow` is the person's standing permission for one exact step (plan HO1): { kind, target, rules }. A rule that++ * covers this kind and this target lets the step go on at any level. The floor is checked first and is never++ * covered by a rule: money, a password, a deletion and a new recipient ask every time.*/const LEVELS = ['ask', 'auto-edits', 'approve-for-me', 'full'];@@@ -76,9 +80,13 @@ const floorFor = (categories) => (Array.isArray(categories) ? categories : []).f* anything else for an outward action. Returns { ask, floor, reason } — and for approve-for-me whose reviewer approves,* { ask: false, reviewer }. Never returns ask=false for a floor category.*/--async function decide({ level = 'ask', categories = [], act = 'act', models = null, summary = '', payload = null, signal = null } = {}) {++async function decide({ level = 'ask', categories = [], act = 'act', models = null, summary = '', payload = null, signal = null, allow = null } = {}) {const floored = floorFor(categories);if (floored.length) return { ask: true, floor: floored[0], categories: floored, reason: `Always asks before ${floored.join(', ')} at every level.` };++ // The person's own standing permission for this exact step (plan HO1): it covers the level, never the floor.++ if (allow && require('./allows').matches(allow.rules, allow.kind, allow.target)) {++ return { ask: false, allowed: true, reason: 'You allowed this for this agent.' };++ }if (level === 'ask') return { ask: true, reason: 'This agent asks before it acts.' };if (level === 'auto-edits' && (act === 'code' || act === 'edit')) return { ask: false, reason: 'Auto-accept edits.' };if (level === 'auto-edits') return { ask: true, reason: 'Auto-accept edits covers edits; other actions ask.' };@@@ -305,7 +305,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>const step = stepFor(name, a);if (step) {let ask;-- try { ask = await requestApproval({ kind: 'actor.action', ...step }); }++ try { ask = await requestApproval({ ...step, kind: step.kind || 'actor.action' }); }catch (err) { return json({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }); }if (ask && ask.pending) return json({ error: `not started: the person must approve it first at ${ask.url}`, approval: { id: ask.id, url: ask.url } });}@@@ -9,7 +9,7 @@ const path = require('path');const { createDb } = require('openvibe-sdk/db');const { ids } = require('openvibe-contracts');--const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state'];++const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state'];const MIGRATIONS = path.join(__dirname, '..', 'migrations');const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');@@@ -10,6 +10,8 @@ const contacts = require('../agents/contacts');const ui = require('./agent-contacts-ui');const approvals = require('../agents/approvals');const levels = require('../agents/levels');++const allows = require('../agents/allows');++const kinds = require('../agents/kinds');/** "22 minutes ago", "in 6 days": a person reads this, the exact UTC instant stays in the <time> element's title. */function ago(iso, now = Date.now()) {@@@ -154,8 +156,8 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}if (!signed(req)) return login(req, res);const rows = await approvals.list(s, owner(req));page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>--<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>--${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">Asked ${time(a.created_at)}</span></li>`)}</ul>`++<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. You may also say "always allow this for this agent": that covers this one kind of step, on this one target, until you revoke it. Spending money, a password, a deletion and a new recipient always ask. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>++${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">${kinds.label(a.kind)} · Asked ${time(a.created_at)}</span></li>`)}</ul>`: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}<p><a href="/me/agents">Your agents</a></p>`);});@@@ -167,24 +169,38 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c// The agent by its name (a person's own agent: another person's is never looked up here).const agent = a.agent_id ? await contacts.get(s, owner(req), a.agent_id).catch(() => null) : null;const agentName = agent && agent.name ? agent.name : 'your agent';-- // The always-ask floor, said in plain words before anything technical.-- // A raw command deletes by its argv (rm, shred …), which words alone do not show: the same check stepFor uses.-- const argvDeletes = p && Array.isArray(p.argv) && require('../agents/actions').deletes(p.argv);-- const floor = [...new Set([...levels.classify({ summary: a.summary, payload: p }), ...(argvDeletes ? ['delete'] : [])])].filter((c) => CAUTION[c]);-- const cmd = a.kind === 'actor.action' ? commandLine(p) : null;++ // The always-ask floor, said in plain words before anything technical: what the request stored, what its words++ // say, and a raw command that deletes by its argv (rm, shred …), the same checks approvals.decide uses.++ const floor = approvals.floorOf(a).filter((c) => CAUTION[c]);++ const coding = a.kind === 'codes.task';++ const cmd = coding ? null : commandLine(p);const nowMs = Date.parse(s.iso()) || Date.now(); // the service's own clock (a test's clock in a test)const where = p && typeof p.machine === 'string' && p.machine ? p.machine : null;++ // "Always allow" (plan HO1): this one kind of step on this one exact target, for this agent. Never a floor step.++ const canAllow = a.state === 'pending' && !floor.length && Boolean(a.agent_id) && Boolean(a.target);++ const allowButton = canAllow++ ? html`<button type="submit" name="decision" value="approve_always" class="pa-btn">Always allow this for ${agentName}</button>`++ : '';++ const allowHint = a.state !== 'pending' ? ''++ : floor.length ? html`<p class="pa-hint">This always asks, so it can never be always allowed.</p>`++ : canAllow ? html`<p class="pa-hint">Always allowing covers exactly this, and nothing else: ${kinds.phrase(a.kind, a.target)}. Anything different still asks. You can take it back on <a href="/me/agents/${a.agent_id}#always">${agentName}'s page</a>.</p>`++ : '';++ const decided = a.state === 'approved'++ ? (a.decision_scope === 'always' ? `You approved this and allowed it from now on for ${agentName}.` : 'You approved this once.')++ : (a.state === 'declined' ? 'You declined this.' : 'Nobody answered this one in time.');++ const buttons = a.state === 'pending'++ ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${coding ? 'Approve and start it' : 'Approve for a retry'}</button> ${allowButton} <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>${allowHint}`++ : html`<p class="pa-hint">${decided}${a.decided_at ? html` Decided ${whenAt(a.decided_at, nowMs)}.` : ''}</p>${a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`;page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>--<p>${approvalPill(a)} <span class="pa-when">Asked ${whenAt(a.created_at, nowMs)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">${agentName}</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}${a.state === 'pending' && a.expires_at ? html` · expires ${whenAt(a.expires_at, nowMs)}` : ''}</span></p>++<p>${approvalPill(a)} <span class="pa-when">${kinds.label(a.kind)} · Asked ${whenAt(a.created_at, nowMs)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">${agentName}</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}${a.state === 'pending' && a.expires_at ? html` · expires ${whenAt(a.expires_at, nowMs)}` : ''}</span></p>${floor.length ? html`<p class="pa-caution" role="note">${floor.map((c) => CAUTION[c]).join(' ')}</p>` : ''}--${a.kind === 'actor.action'++${!coding? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`: html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p><pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`}--${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${a.kind === 'actor.action' ? 'Approve for a retry' : 'Approve and start it'}</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`-- : a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`);++${buttons}`);});r.post('/me/approvals/:id', async (req, res) => {if (!write(req, res)) return;@@@ -201,6 +217,10 @@ ${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}const pendingSuggestions = suggestions.filter((p) => p.state === 'pending');const applied = suggestions.filter((p) => p.state === 'applied').slice(0, 5);const runs = await s.db.many('SELECT id, state, created_at, result, cost_usd, error_detail FROM tasks WHERE requester = $1 AND via = $2 ORDER BY id DESC LIMIT 20', [a.owner, `agent:${a.id}`]);++ // What this agent may always do (plan HO1), and every decision the person made about its asks.++ const allowRules = await allows.list(s, a.owner, a.id);++ const decisions = await s.db.many(`SELECT id, kind, summary, state, decision_scope, decided_by, decided_at, created_at++ FROM actor_approvals WHERE owner = $1 AND agent_id = $2 ORDER BY created_at DESC LIMIT 20`, [a.owner, a.id]);// Its Vibe chat (agents/vibe-deliver.js), once a run has gone there.const vibeChat = a.send_to_vibe ? await s.db.maybe("SELECT chat_url FROM actor_vibe_deliveries WHERE agent_id = $1 AND owner = $2 AND state = 'sent' AND chat_url IS NOT NULL ORDER BY sent_at DESC LIMIT 1", [a.id, a.owner]) : null;// The run journal: each run's first line (its answer, or why it did not finish) and what it cost.@@@ -225,6 +245,13 @@ ${a.last_skip_reason ? html`<p class="notice" role="status">The last scheduled r<p class="pa-actions"><a class="pa-btn" href="https://openvibe.watch/watches/new?template=event&agent_id=${a.id}">When something happens on OpenVibe</a> <a class="pa-btn" href="https://openvibe.watch/watches/new?template=page&agent_id=${a.id}">When a page changes</a></p></section><section class="pa-panel"><h2>Recent runs</h2>${vibeChat && /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/.test(vibeChat.chat_url) ? html`<p class="pa-hint">Each run also lands in <a href="${vibeChat.chat_url}">its Vibe chat</a>, where you can ask Vibe about it.</p>` : ''}${runs.length ? html`<ul class="pa-runs">${runs.map((t) => html`<li><a href="/tasks/${t.id}">${time(t.created_at)}</a> <span class="pill ${pill(t.state)}">${t.state}</span>${usd(t.cost_usd)}${gist(t) ? html`<span class="pa-run-gist">${gist(t)}</span>` : ''}</li>`)}</ul>` : html`<p class="muted">No run yet.</p>`}</section></div>++<section class="pa-panel" id="always" aria-labelledby="pa-always-h"><h2 id="pa-always-h">What it may always do</h2>++<p class="pa-hint">When you said "always allow this for this agent", the permission is here: one kind of step, on one target. Take any of them back and the next one asks again. Spending money, a password, a deletion and a new recipient always ask, whatever is allowed here.</p>++${allowRules.length ? html`<ul class="pa-notes">${allowRules.map((r) => html`<li><p class="pa-note-text">${kinds.phrase(r.kind, r.target)}</p><p class="pa-note-meta">Allowed ${time(r.created_at)}</p>++<form method="post" action="/me/agents/${a.id}/allows/${r.id}/revoke" class="pa-actions"><button type="submit" class="pa-danger-link">Revoke</button></form></li>`)}</ul>` : html`<p class="muted">Nothing is always allowed for this agent.</p>`}</section>++<section class="pa-panel" id="decisions" aria-labelledby="pa-dec-h"><h2 id="pa-dec-h">What you decided</h2>++<p class="pa-hint">Every approval this agent asked for, and what you answered: once, always, or no.</p>++${decisions.length ? html`<ul class="pa-notes">${decisions.map((d) => html`<li><p class="pa-note-text"><a href="/me/approvals/${d.id}">${d.summary}</a></p><p class="pa-note-meta">${kinds.label(d.kind)} · ${d.state === 'approved' ? (d.decision_scope === 'always' ? 'You approved it and allowed it always' : 'You approved it once') : d.state === 'declined' ? 'You declined it' : d.state === 'expired' ? 'It expired, nobody answered' : 'Waiting for you'} · ${time(d.decided_at || d.created_at)}</p></li>`)}</ul>` : html`<p class="muted">This agent has not asked for anything yet.</p>`}</section>${pendingSuggestions.length || applied.length ? html`<section class="pa-panel" aria-labelledby="pa-sugg-h"><h2 id="pa-sugg-h">Suggestions from your agent</h2><p class="pa-hint">After a run that passed its check, the agent may suggest a line for its instructions. Nothing changes until you add it, and you can undo it.</p>${pendingSuggestions.length ? html`<ul class="pa-notes">${pendingSuggestions.map((p) => html`<li><p class="pa-note-text">${p.text}</p><p class="pa-note-meta">Suggested ${time(p.created_at)}${p.from_task ? html` after <a href="/tasks/${p.from_task}">this run</a>` : ''}</p>@@@ -264,6 +291,15 @@ ${notes.length ? html`<p><a href="/memory">See all your memory</a> · <a href="/r.post('/me/agents/:id/memory/:mem/delete', async (req, res) => { if (!write(req, res)) return; const a = await find(req, res); if (!a) return; await s.db.query('DELETE FROM actor_agent_memory WHERE id = $1 AND agent_id = $2 AND owner = $3', [req.params.mem, a.id, a.owner]); res.redirect(303, `/me/agents/${a.id}`); });r.get('/me/agents/:id/forget', async (req, res) => { if (!signed(req)) return login(req, res); const a = await find(req, res); if (!a) return; page(req, res, 'Forget memory', html`<h1>Forget all memory for ${a.name}?</h1><form method="post" action="/me/agents/${a.id}/forget"><button type="submit">Forget everything</button></form><p><a href="/me/agents/${a.id}">Cancel</a></p>`); });r.post('/me/agents/:id/forget', async (req, res) => { if (!write(req, res)) return; const a = await find(req, res); if (!a) return; await s.db.query('DELETE FROM actor_agent_memory WHERE agent_id = $1 AND owner = $2', [a.id, a.owner]); res.redirect(303, `/me/agents/${a.id}`); });++ // Take back a standing permission (plan HO1): the next identical step asks again.++ r.post('/me/agents/:id/allows/:allowId/revoke', async (req, res) => {++ if (!write(req, res)) return;++ const a = await find(req, res); if (!a) return;++ const rule = await allows.get(s, a.owner, req.params.allowId);++ if (!rule || rule.agent_id !== a.id) return page(req, res, 'Not found', html`<h1>Not found</h1><p><a href="/me/agents/${a.id}">Back to the agent</a></p>`, 404);++ await allows.revoke(s, a.owner, req.params.allowId);++ res.redirect(303, `/me/agents/${a.id}#always`);++ });return r;}@@@ -95,9 +95,20 @@ function createApi(ctx) {lastEvent: store.parseJson(row.last_event), steps: Number(row.tool_steps),wait: waitFor.get(row.id) || null, now: s.now,}));-- const asks = (await approvals.list(s, requester))-- .filter((a) => a.state === 'pending')-- .map(items.approvalItem);++ // A waiting approval reaches the Action Center as an item a person can read: the kind in plain words, the++ // agent's own name (never its id) and the thread it came from (plan HO1).++ const pending = (await approvals.list(s, requester)).filter((a) => a.state === 'pending');++ const agents = await s.db.many('SELECT id, name FROM actor_agents WHERE owner = $1', [requester]);++ const agentName = new Map(agents.map((a) => [a.id, a.name]));++ const threadIds = pending.map((a) => a.task_id).filter(Boolean);++ const threads = threadIds.length++ ? await s.db.many('SELECT id, task FROM tasks WHERE id = ANY($1)', [threadIds])++ : [];++ const threadTitle = new Map(threads.map((t) => [t.id, items.plain(t.task, 160)]));++ const asks = pending.map((a) => items.approvalItem(a, {++ agentName: a.agent_id ? agentName.get(a.agent_id) || null : null,++ thread: a.task_id ? threadTitle.get(a.task_id) || null : null,++ }));const scheduled = (await personal.list(s, requester)).filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at).map(items.scheduledItem);@@@ -115,7 +126,8 @@ function createApi(ctx) {if (!['approve', 'decline'].includes(decision)) return problem(req, res, 422, 'actor.approval.invalid', 'decision must be approve or decline');const out = await approvals.decide(ctx, `user:${subject}`, req.params.id, decision, { idempotent: true });if (out.error) return problem(req, res, out.status, out.status === 404 ? 'actor.approval.not_found' : 'actor.approval.invalid', out.error);-- res.json(items.approvalItem(out.row));++ const named = out.row && out.row.agent_id ? await s.db.maybe('SELECT name FROM actor_agents WHERE id = $1', [out.row.agent_id]) : null;++ res.json(items.approvalItem(out.row, { agentName: named ? named.name : null }));});// ── The catalog, the contacts and the dry run ────────────@@@ -71,6 +71,8 @@ const TABLES = [// What the person's agents asked them to approve (agents/approvals.js), and what they decided.{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },++ // The standing permissions the person gave their agents (agents/allows.js): what they always allow, and when.++ { table: 'actor_approval_allows', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approval-allows.json' },// The agent's timeline (design 2026-11-07): the steps the agent took for this person, and whether they paused// it. Titles, outcomes and links only: there is no column for a prompt or a diff, so none can be exported.{ table: 'agent_timeline', subject: 'subject', value: (usr) => `user:${usr}`, file: 'timeline.json',@@@ -162,7 +162,8 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchIif (step) {let ask;try {-- ask = await ctx.requestApproval({ kind: 'actor.action', ...step });++ // The step says which kind of act it is (plan HO1); an unknown tool is an action of a connected tool.++ ask = await ctx.requestApproval({ ...step, kind: step.kind || 'actor.action' });} catch (err) {return normalize({ text: JSON.stringify({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }), isError: true });}@@@ -29,6 +29,7 @@ const { route, whyNone } = require('../agents/router');const { check } = require('../agents/check');const { check: checkClaims } = require('../runtime/check');const approvals = require('../agents/approvals');++const allows = require('../agents/allows');const contacts = require('../agents/contacts');const assignments = require('../agents/assignments');const runTokens = require('../triggers/tokens');@@@ -333,11 +334,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =let closeAssignment = null;// One place every approval gate asks through: the level, then the always-ask floor, then (for approve-for-me)// a reviewer model of another family. Returns { asked: false } when the step may go ahead.-- const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT }) => {++ // A standing permission the person gave for this exact kind and target (plan HO1) is read here: it lets the++ // step go on, and it never covers the floor.++ const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT, target = null }) => {const categories = levels.classify({ category, summary, payload, recipientKnown });-- const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal });++ const rules = taskAgent ? await allows.list(s, row.requester, taskAgent.id) : [];++ const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal, allow: { kind, target, rules } });if (!decision.ask) return { asked: false, decision };-- const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary, payload });++ const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary, payload, target, categories });const url = `${config.baseUrl}/me/approvals/${ask.id}`;await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(summary).toLowerCase()} Nothing runs until you approve it: ${url}` });await tellPerson(row, 'needs_you', { what: `approve: ${summary}`, url });@@@ -426,8 +430,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// A task that would start coding work on the person's machine asks through the same gate (levels.js):// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a// payment, password, deletion or a new recipient never does.-- requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null }) => {-- const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown });++ requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, target = null }) => {++ const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown, target });if (!out.asked) return null;return { pending: true, id: out.ask.id, url: out.url };} : null;@@@ -654,7 +658,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =if (agentId === 'codes' && taskAgent && codesRepoIn(row.task)) {const asked = await askForApproval({kind: 'codes.task', summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`,-- payload: { task: row.task }, act: 'edit', agent: agentId,++ payload: { task: row.task }, act: 'edit', agent: agentId, target: require('../agents/actions').repoTarget(codesRepoIn(row.task)),});if (asked.asked) {const result = {@@@ -72,6 +72,7 @@ const DAY = 86_400_000;const declined = `apr_${ids.ulid()}`;const oldApproval = `apr_${ids.ulid()}`;const theirsApproval = `apr_${ids.ulid()}`;++ const thread = ids.newId('task');const agent = ids.newId('agent');const paused = ids.newId('agent');const off = ids.newId('agent');@@@ -84,9 +85,11 @@ const DAY = 86_400_000;await addTask({ id: cancelled, state: 'cancelled', text: 'Stopped task', created: now - 5 * DAY, finished: now - 4 * DAY });await addTask({ id: old, state: 'succeeded', text: 'Ancient', created: now - 45 * DAY, finished: now - 40 * DAY });await addTask({ id: theirs, requester: `user:${bo.subject}`, state: 'running', text: 'Bo\'s task' });++ // The thread the approval came from (plan HO1): its own words are what the hub shows.++ await addTask({ id: thread, state: 'running', text: 'Fix the failing test in the repository' });-- await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)-- VALUES ($1,$2,$3,'codes.task','Approve the fix', $4::jsonb,'pending',$5,$6,NULL)`, [approval, `user:${ada.subject}`, agent, JSON.stringify({ task: 'Fix the **bug** in the repository' }), iso(now - 3600_000), iso(now + 6 * DAY)]);++ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, task_id, kind, summary, payload, state, created_at, expires_at, decided_at)++ VALUES ($1,$2,$3,$4,'codes.task','Approve the fix', $5::jsonb,'pending',$6,$7,NULL)`, [approval, `user:${ada.subject}`, agent, thread, JSON.stringify({ task: 'Fix the **bug** in the repository' }), iso(now - 3600_000), iso(now + 6 * DAY)]);await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)VALUES ($1,$2,$3,'codes.task','Already declined', '{}'::jsonb,'declined',$4,$5,$4)`, [declined, `user:${ada.subject}`, agent, iso(now - 2 * DAY), iso(now + 5 * DAY)]);await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)@@@ -116,7 +119,10 @@ const DAY = 86_400_000;assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);assert.ok(byId.has(approval) && byId.get(approval).bucket === 'waiting', 'the pending approval waits');assert.deepStrictEqual(byId.get(approval).actions, ['approve', 'decline', 'open']);-- assert.strictEqual(byId.get(approval).summary, 'Fix the bug in the repository');++ // The kind in plain words, and the agent by its name (never its raw id).++ assert.strictEqual(byId.get(approval).title, 'Approve the fix');++ assert.strictEqual(byId.get(approval).summary, 'a coding task on your machine · Fix the failing test in the repository');++ assert.strictEqual(byId.get(approval).agent, 'Morning report');assert.ok(!byId.has(declined), 'a decided approval is no longer an action waiting on the person');assert.deepStrictEqual([byId.get(agent).bucket, byId.get(agent).state, byId.get(agent).next_run_at], ['scheduled', 'scheduled', iso(now + 5 * 3600_000)]);assert.ok(!byId.has(paused) && !byId.has(off), 'paused and off agents are not scheduled');nnew file mode 100644@@@ -0,0 +1,316 @@++'use strict';++/**++ * HO1, universal approvals: the trust spine.++ *++ * Every acting tool says which kind of act it is (agents/actions.js, agents/kinds.js) and a plain summary of it;++ * every pending approval reaches the Action Center as a waiting item with the kind, the agent's NAME and its own++ * page; the person may approve once, or approve and allow that exact kind of step for that agent (scoped by kind++ * and target, revocable); the always-ask floor (money, a password, a deletion, a new recipient) is never covered by++ * such a permission; and every decision is in the agent's own activity.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++const { createRegistry } = require('../server/runtime/tools');++const { stepFor } = require('../server/agents/actions');++const kinds = require('../server/agents/kinds');++const levels = require('../server/agents/levels');++const approvals = require('../server/agents/approvals');++const allows = require('../server/agents/allows');++const contacts = require('../server/agents/contacts');++const items = require('../server/actions/items');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++// Every acting tool and the kind it is. `null` means Actor's own working state: it never asks.++const EXPECTED = {++ codes_task: 'codes.task',++ computer_browser: 'computer.browser',++ computer_desk: 'computer.desk',++ computer_input: 'computer.input',++ computer_run: 'computer.run',++ computer_write: 'computer.write',++ hand_to_person: null,++ make_chart: null,++ make_file: null,++ remember: null,++ robot_steps: 'robot.move',++ run_tool: 'by-tool',++ schedule_trigger: 'routine',++ space_create: 'space',++ space_files_write: 'space',++ space_publish: 'publish',++};++++const SAMPLE = {++ codes_task: { repo: 'ada/site', instruction: 'fix the bug' },++ computer_browser: { machine: 'dev_x', action: 'click', text: 'Send' },++ computer_desk: { machine: 'dev_x', action: 'start' },++ computer_input: { machine: 'dev_x', action: 'type', text: 'hello' },++ computer_run: { machine: 'dev_x', argv: ['git', 'status'] },++ computer_write: { machine: 'dev_x', path: '/tmp/a.txt', text: 'hi' },++ hand_to_person: { text: 'look at this' },++ make_chart: { name: 'c' },++ make_file: { name: 'n.md', text: 'hi' },++ remember: { text: 'note' },++ robot_steps: { robot: 'r1', steps: ['forward'] },++ run_tool: { tool: 'openvibe.blog.post', input: { site: 'openvibe.blog' } },++ schedule_trigger: { cron: '0 9 * * *', task: 'digest' },++ space_create: { name: 'My space' },++ space_files_write: { space_id: 'sp_1', files: [{ path: 'a.txt', content: 'x' }] },++ space_publish: { space_id: 'sp_1' },++};++++(async () => {++ // ── Every acting tool has a kind ──++ await check('every acting tool is mapped to a kind, and every kind has plain words', async () => {++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });++ const acting = registry.tools.filter((t) => t.acts === true).map((t) => t.name).sort();++ assert.deepStrictEqual(acting, Object.keys(EXPECTED).sort(), 'every acting tool is in the list; classify a new one');++ for (const name of acting) {++ const step = stepFor(name, SAMPLE[name]);++ if (EXPECTED[name] === null) {++ assert.strictEqual(step, null, `${name}: Actor's own working state never asks`);++ continue;++ }++ assert.ok(step, `${name}: the step must say what it is`);++ assert.ok(kinds.isKind(step.kind), `${name}: ${step.kind} is a known kind`);++ assert.ok(kinds.label(step.kind).length > 3, `${name}: the kind reads as plain words`);++ assert.ok(step.summary && step.summary.length > 3, `${name}: a plain summary`);++ // A target names the step exactly (it is what a standing permission matches), or is null: then the step++ // can never be always allowed (typing, a click on whatever page is open, a send, an install, money).++ assert.ok(step.target === null || (typeof step.target === 'string' && step.target.length > 1 && step.target.length <= allows.MAX_TARGET),++ `${name}: an exact target or none`);++ }++ assert.strictEqual(stepFor('computer_run', SAMPLE.computer_run).target, 'git status on dev_x');++ assert.strictEqual(stepFor('computer_input', SAMPLE.computer_input).target, null, 'typing is never always allowed');++ assert.strictEqual(stepFor('computer_browser', SAMPLE.computer_browser).target, null, 'a click acts on whatever page is open');++ assert.strictEqual(stepFor('computer_browser', { machine: 'dev_x', action: 'open', url: 'https://openvibe.blog/' }).target, 'open https://openvibe.blog/ on dev_x');++ });++++ await check('a target names the step exactly: never cut, never folded, two commands never read the same', async () => {++ const run = (argv, extra = {}) => stepFor('computer_run', { machine: 'dev_x', argv, ...extra }).target;++ // Quoting: one word with a space is not two words.++ assert.notStrictEqual(run(['echo', 'a b']), run(['echo', 'a', 'b']));++ assert.strictEqual(run(['echo', 'a b']), 'echo "a b" on dev_x');++ // Whitespace is never folded, and a newline is quoted, never a separator.++ assert.notStrictEqual(run(['sh', '-c', 'make test']), run(['sh', '-c', 'make test']));++ assert.strictEqual(run(['sh', '-c', 'make\nrm -rf ~']), 'sh -c "make\\nrm -rf ~" on dev_x');++ // The working directory is part of the step.++ assert.notStrictEqual(run(['make'], { cwd: '/a' }), run(['make'], { cwd: '/b' }));++ // A command too long to name exactly has no target: it can never be always allowed (a cut target would also++ // match any longer command that starts the same way).++ const long = ['python', 'job.py', ...Array.from({ length: 40 }, (_, i) => `--flag-${i}`)];++ assert.strictEqual(run(long), null);++ assert.strictEqual(allows.exact('x'.repeat(allows.MAX_TARGET + 1)), null);++ assert.strictEqual(allows.exact('git status\u0000 on dev_x'), null, 'no control characters');++ assert.strictEqual(allows.matches([{ kind: 'computer.run', target: 'x'.repeat(200) }], 'computer.run', 'x'.repeat(201)), false);++ // A machine the agent names is quoted too, so it cannot pose as part of the command.++ assert.strictEqual(stepFor('computer_run', { machine: 'dev_x foo', argv: ['ls'] }).target, 'ls on "dev_x foo"');++ // A routine names what repeats and when: allowing a schedule never allows another task on it.++ const daily = (task) => stepFor('schedule_trigger', { cron: '0 9 * * *', task }).target;++ assert.notStrictEqual(daily('digest'), daily('wire the money'));++ assert.match(daily('digest'), /^"digest" cron "0 9 \* \* \*"$/);++ // OpenVibe.Tools: a post is allowed for one tool and one place; a send, an install and money never.++ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.blog.post', input: { site: 'openvibe.blog' } }).target, 'openvibe.blog.post to site openvibe.blog');++ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.blog.post', input: {} }).target, null, 'no place named, no target');++ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.mail.send', input: { to: '[email protected]' } }).target, null);++ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.tools.install_skill', input: { name: 'x' } }).target, null);++ const pay = stepFor('run_tool', { tool: 'openvibe.wallet.pay', input: {} });++ assert.strictEqual(pay.target, null);++ assert.strictEqual(pay.category, 'money', 'money is the floor whatever the words say');++ });++++ await check('some kinds can never be always allowed, whatever the target', async () => {++ for (const kind of ['money', 'computer.input', 'send', 'install']) {++ assert.strictEqual(kinds.allowable(kind), false, `${kind} always asks`);++ assert.strictEqual(allows.matches([{ kind, target: 't' }], kind, 't'), false, `${kind}: no rule covers it`);++ }++ const d = await levels.decide({ level: 'ask', categories: [], act: 'act', allow: { kind: 'computer.input', target: 'type on dev_x', rules: [{ kind: 'computer.input', target: 'type on dev_x' }] } });++ assert.strictEqual(d.ask, true, 'a stored rule for a never-allowed kind is ignored');++ });++++ await check('the kinds cover the trust spine: computer, money, publishing, posting, sending, installing, routines', async () => {++ for (const kind of ['computer.run', 'computer.write', 'computer.input', 'computer.browser', 'money', 'publish', 'post', 'send', 'install', 'routine']) {++ assert.ok(kinds.isKind(kind), `${kind} is a kind`);++ assert.ok(kinds.label(kind), `${kind} has plain words`);++ }++ // A generic OpenVibe.Tools tool takes the kind of the tool it runs; a plain lookup still does not ask.++ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.blog.post', input: {} }).kind, 'post');++ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.tools.install_skill', input: {} }).kind, 'install');++ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.mail.send', input: {} }).kind, 'send');++ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.wallet.pay', input: {} }).kind, 'money');++ assert.strictEqual(stepFor('run_tool', { tool: 'dns.lookup', input: {} }), null, 'a lookup stays a lookup');++ });++++ await check('the table accepts every kind the code can ask for', async () => {++ // The migration widens the check on the kinds column; the two lists must not drift apart.++ const dir = path.join(__dirname, '..', 'migrations');++ const file = fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort()++ .map((f) => ({ f, sql: fs.readFileSync(path.join(dir, f), 'utf8') }))++ .filter((m) => m.sql.includes('actor_approvals_kind_check')).pop();++ assert.ok(file, 'the migration that widens the kinds is there');++ const listed = (file.sql.match(/CHECK \(kind IN \(([^)]*)\)\)/) || ['', ''])[1]++ .split(',').map((k) => k.trim().replace(/^'|'$/g, '')).filter(Boolean);++ assert.deepStrictEqual(listed, kinds.KINDS, `${file.f} and agents/kinds.js agree on the kinds`);++ });++++ await check('the Action Center item carries the kind, the agent by name, and its own page', async () => {++ const item = items.approvalItem({++ id: 'apr_01JAB2C3D4E5F6G7H8J9K0MNPS', kind: 'publish', summary: 'Publish your post on openvibe.blog',++ state: 'pending', created_at: '2026-11-07T10:00:00.000Z', agent_id: 'agt_01JAB2C3D4E5F6G7H8J9K0MNPS',++ }, { agentName: 'Vibe', thread: 'Weekly digest' });++ assert.strictEqual(item.service, 'actor');++ assert.strictEqual(item.kind, 'approval');++ assert.strictEqual(item.bucket, 'waiting');++ assert.strictEqual(item.title, 'Publish your post on openvibe.blog');++ assert.match(item.summary, /publishing for others to see/, 'the kind in plain words');++ assert.match(item.summary, /Weekly digest/, 'the thread it came from');++ assert.strictEqual(item.agent, 'Vibe', 'the agent by its name, never its id');++ assert.strictEqual(item.href, 'https://openvibe.actor/me/approvals/apr_01JAB2C3D4E5F6G7H8J9K0MNPS');++ });++++ // ── The engine: ask, allow always, and take it back ──++ const asked = [];++ const t = await boot({++ loop: {++ close: async () => {},++ run: async (args) => {++ // One step of each kind the task asks for: the same command, and another one.++ const step = stepFor('computer_run', { machine: 'dev_x', argv: args.task.includes('other') ? ['git', 'log'] : ['git', 'status'] });++ const ask = await args.requestApproval({ ...step });++ asked.push({ task: args.task, pending: Boolean(ask && ask.pending) });++ return { text: 'Done.', sources: [], evidence: [], model: 'fake' };++ },++ },++ });++ try {++ const ada = t.network.addUser('ada');++ const owner = `user:${ada.subject}`;++ const agent = (await contacts.create(t.ctx.s, t.config, owner, { name: 'Fixer', level: 'ask' })).row;++ const ask = (task) => t.get('/api/v1/tasks', { as: ada, json: { task, agent_id: agent.id }, headers: SAME });++ const pending = async () => (await approvals.list(t.ctx.s, owner)).filter((a) => a.state === 'pending');++++ await check('a consequential step asks, and the request says what kind of act it is', async () => {++ const res = await ask('run git status on dev_x');++ assert.strictEqual(res.status, 201, res.text);++ await t.waitFor(res.json().id);++ const rows = await pending();++ assert.strictEqual(rows.length, 1, 'one request waits');++ const row = rows[0];++ assert.strictEqual(row.kind, 'computer.run');++ assert.strictEqual(row.target, 'git status on dev_x');++ assert.match(row.summary, /Run "git status" on your computer/);++ assert.ok(row.categories !== undefined, 'the floor categories are stored');++ assert.strictEqual(row.agent_id, agent.id);++ // The page reads in plain words, and offers both ways to say yes.++ const page = await t.get(`/me/approvals/${row.id}`, { as: ada });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /running a program on your computer/, 'the kind in plain words');++ assert.match(page.text, /Always allow/, 'the person may allow it always');++ assert.match(page.text, /Approve and start it|Approve for a retry/);++ assert.match(page.text, /Fixer/, 'the agent by its name');++ });++++ await check('approving once answers the request and leaves the agent asking next time', async () => {++ const row = (await pending())[0];++ const res = await t.get(`/me/approvals/${row.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(res.status, 303);++ const after = await approvals.get(t.ctx.s, owner, row.id);++ assert.strictEqual(after.state, 'approved');++ assert.strictEqual(after.decision_scope, 'once');++ assert.strictEqual(after.decided_by, owner, 'who decided');++ assert.strictEqual((await allows.list(t.ctx.s, owner, agent.id)).length, 0, 'nothing is always allowed yet');++ // The same step asks again.++ const res2 = await ask('run git status on dev_x');++ await t.waitFor(res2.json().id);++ assert.strictEqual((await pending()).length, 1, 'it asks again');++ });++++ await check('approve and always allow: the next identical ask does not ask, another target does', async () => {++ const row = (await pending())[0];++ const page = await t.get(`/me/approvals/${row.id}`, { as: ada });++ assert.match(page.text, /Always allow this for Fixer/);++ assert.match(page.text, /covers exactly this, and nothing else: running a program on your computer · git status on dev_x/);++ const res = await t.get(`/me/approvals/${row.id}`, { as: ada, form: { decision: 'approve_always' }, headers: SAME });++ assert.strictEqual(res.status, 303);++ const after = await approvals.get(t.ctx.s, owner, row.id);++ assert.strictEqual(after.state, 'approved');++ assert.strictEqual(after.decision_scope, 'always');++ assert.ok(after.allow_id, 'the standing permission is named');++ const rules = await allows.list(t.ctx.s, owner, agent.id);++ assert.strictEqual(rules.length, 1);++ assert.deepStrictEqual([rules[0].kind, rules[0].target], ['computer.run', 'git status on dev_x']);++++ // The same command on the same machine goes on without asking.++ const before = (await pending()).length;++ const res2 = await ask('run git status on dev_x');++ await t.waitFor(res2.json().id);++ assert.strictEqual((await pending()).length, before, 'the identical step does not ask again');++ const last = asked[asked.length - 1];++ assert.strictEqual(last.pending, false, 'the gate let it through');++++ // Another command is another target: it asks.++ const res3 = await ask('run other git log on dev_x');++ await t.waitFor(res3.json().id);++ const rows = await pending();++ assert.strictEqual(rows.length, before + 1, 'a different target asks again');++ assert.strictEqual(rows[rows.length - 1].target, 'git log on dev_x');++ });++++ await check('the agent\'s page lists what it may always do, with Revoke, and the decision is in its activity', async () => {++ const page = await t.get(`/me/agents/${agent.id}`, { as: ada });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /running a program on your computer · git status on dev_x/, 'the standing permission is listed');++ assert.match(page.text, /You approved it and allowed it from now on|You approved it and allowed it always/, 'the decision is in the agent\'s activity');++ assert.match(page.text, /You approved it once/, 'the earlier decision is there too');++ assert.match(page.text, /Revoke/);++ });++++ await check('revoking restores the ask', async () => {++ const rules = await allows.list(t.ctx.s, owner, agent.id);++ const res = await t.get(`/me/agents/${agent.id}/allows/${rules[0].id}/revoke`, { as: ada, method: 'POST', headers: SAME });++ assert.strictEqual(res.status, 303);++ assert.strictEqual((await allows.list(t.ctx.s, owner, agent.id)).length, 0, 'the permission is gone');++ const before = (await pending()).length;++ const res2 = await ask('run git status on dev_x');++ await t.waitFor(res2.json().id);++ assert.strictEqual((await pending()).length, before + 1, 'it asks again');++ });++++ await check('the floor is never covered by a standing permission', async () => {++ const rows = await pending();++ const money = rows.find((r) => r.kind === 'computer.run' && r.target === 'git status on dev_x');++ assert.ok(money, 'the waiting request');++ // A deletion is a floor step: the page offers no "always allow", and the answer is refused.++ const del = await approvals.request(t.ctx.s, {++ owner, agentId: agent.id, kind: 'computer.run', summary: 'Run "rm -rf build" on your computer',++ payload: { argv: ['rm', '-rf', 'build'] }, target: 'rm -rf build on dev_x', categories: ['delete'],++ });++ const page = await t.get(`/me/approvals/${del.id}`, { as: ada });++ assert.ok(!/Always allow/.test(page.text), 'a floor step has no always-allow button');++ assert.match(page.text, /This always asks, so it can never be always allowed/);++ assert.match(page.text, /This deletes something/, 'the caution says why');++ const res = await t.get(`/me/approvals/${del.id}`, { as: ada, form: { decision: 'approve_always' }, headers: SAME });++ assert.strictEqual(res.status, 422);++ assert.match(res.text, /can never be always allowed/);++ assert.strictEqual((await allows.list(t.ctx.s, owner, agent.id)).length, 0, 'nothing was allowed');++ // And the floor asks even at full access, even with a rule for the same kind.++ const decision = await levels.decide({++ level: 'full', categories: ['money'], act: 'act', summary: 'Spend $10',++ allow: { kind: 'money', target: 'example.com', rules: [{ kind: 'money', target: 'example.com' }] },++ });++ assert.strictEqual(decision.ask, true, 'the floor asks whatever the rules say');++ });++++ await check('the decision is in the person\'s feed too', async () => {++ const feed = await t.get('/console', { as: ada });++ assert.strictEqual(feed.status, 200);++ assert.match(feed.text, /You approved/, 'the person sees what they approved');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -155,7 +155,7 @@ function gate(level, asked) {assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));const ask = await t.ctx.s.db.maybe('SELECT * FROM actor_approvals WHERE task_id = $1', [row.id]);assert.ok(ask, 'the approval was recorded');-- assert.deepStrictEqual([ask.kind, ask.state], ['actor.action', 'pending']);++ assert.deepStrictEqual([ask.kind, ask.state], ['computer.run', 'pending']);assert.strictEqual(JSON.parse(typeof ask.payload === 'string' ? ask.payload : JSON.stringify(ask.payload)).argv[0], 'rm');});
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+-- phase: expand
+-- HO1 (universal approvals, the trust spine): every consequential act has a kind, so the person reads one plain
+-- phrase for it on the approval page, in the Action Center and on the agent's own page. The kinds widen; every old
+-- row stays valid and keeps its kind (codes.task, actor.action).
+--
+-- The request now also carries the exact TARGET it would touch ("publish to openvibe.blog", "run git status on
+-- dev_x") and the always-ask categories it fell in, so the decision can be scoped and the floor can be checked
+-- later without re-reading the tool's input.
+--
+-- "Always allow this for this agent" (agents/allows.js) is one row per (person, agent, kind, target): a standing
+-- permission the person may revoke. It never covers the floor (money, a password, a deletion, a new recipient):
+-- that is checked in levels.js and in agents/approvals.js, not here.
+CREATE TABLE IF NOT EXISTS actor_approval_allows (
+ id text COLLATE "C" PRIMARY KEY, -- alw_<ULID>
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ agent_id text COLLATE "C" NOT NULL, -- agt_… the permission is for
+ kind text COLLATE "C" NOT NULL, -- the kind of act it covers
+ target text COLLATE "C" NOT NULL, -- the exact target it is scoped to
+ created_at text COLLATE "C" NOT NULL,
+ revoked_at text COLLATE "C" -- set when the person takes it back
+);
+CREATE UNIQUE INDEX IF NOT EXISTS actor_approval_allows_scope
+ ON actor_approval_allows (owner, agent_id, kind, target) WHERE revoked_at IS NULL;
+CREATE INDEX IF NOT EXISTS actor_approval_allows_owner
+ ON actor_approval_allows (owner, agent_id, created_at DESC);
+
+ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS target text COLLATE "C";
+ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS categories jsonb;
+ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS decision_scope text COLLATE "C";
+ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS decided_by text COLLATE "C";
+ALTER TABLE actor_approvals ADD COLUMN IF NOT EXISTS allow_id text COLLATE "C";
+
+ALTER TABLE actor_approvals DROP CONSTRAINT IF EXISTS actor_approvals_kind_check;
+ALTER TABLE actor_approvals ADD CONSTRAINT actor_approvals_kind_check CHECK (kind IN (
+ 'codes.task', 'actor.action', 'computer.run', 'computer.write', 'computer.input', 'computer.browser',
+ 'computer.desk', 'robot.move', 'space', 'publish', 'post', 'send', 'install', 'routine', 'money'));
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
*/
*/
const PUBLIC_ORIGIN = 'https://openvibe.actor';
const PUBLIC_ORIGIN = 'https://openvibe.actor';
+const kinds = require('../agents/kinds');
const OPEN = ['queued', 'running', 'verifying'];
const OPEN = ['queued', 'running', 'verifying'];
// Actor's own word → the Action Center bucket. A task whose newest event is a "Needs you:" line is waiting, though
// Actor's own word → the Action Center bucket. A task whose newest event is a "Needs you:" line is waiting, though
@@ -117,18 +118,23 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =
@@ -117,18 +118,23 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =
};
};
}
}
-function approvalItem(row) {
+function approvalItem(row, { agentName = null, thread = null } = {}) {
const bucket = bucketFor(row.state);
const bucket = bucketFor(row.state);
const payload = parse(row.payload) || {};
const payload = parse(row.payload) || {};
+ // The kind in plain words and the thread it came from (plan HO1): a raw agent id says nothing to a person,
+ // so the agent's own name is used, and the kind reads as a phrase. A request with no thread of its own
+ // (an old row) keeps its own words.
+ const detail = (thread ? plain(thread, 200) : null) || (payload.task ? plain(payload.task, 200) : null);
+ const about = [kinds.label(row.kind), detail].filter(Boolean).join(' · ');
return {
return {
service: 'actor',
service: 'actor',
id: row.id,
id: row.id,
kind: 'approval',
kind: 'approval',
title: plain(row.summary || row.id, 160) || row.id,
title: plain(row.summary || row.id, 160) || row.id,
- summary: payload.task ? plain(payload.task, 400) : null,
+ summary: about ? plain(about, 400) : null,
bucket,
bucket,
state: row.state,
state: row.state,
- agent: row.agent_id ? plain(row.agent_id, 80) : null,
+ agent: agentName ? plain(agentName, 80) : null,
cost_usd: null,
cost_usd: null,
steps: null,
steps: null,
error: row.state === 'expired' ? 'This approval expired before it was decided.' : null,
error: row.state === 'expired' ? 'This approval expired before it was decided.' : null,
@@ -1,19 +1,20 @@
@@ -1,19 +1,20 @@
'use strict';
'use strict';
/**
/**
- * Which acting tool steps must see the approval gate first (plan §12 A-2). The gate itself is the
+ * Which acting tool steps must see the approval gate first (plan §12 A-2, plan HO1). The gate itself is the
* engine's: levels.classify + levels.decide, then a row on /me/approvals. This module only says what
* engine's: levels.classify + levels.decide, then a row on /me/approvals. This module only says what
- * a tool call is — its act ('edit' or 'act') and an always-ask category when the call names one — and
- * keeps a secret out of the approval card and the payload.
+ * a tool call is — its kind (agents/kinds.js), its act ('edit' or 'act'), an always-ask category when the call
+ * names one, the exact target it would touch — and keeps a secret out of the approval card and the payload.
*
*
* A tool that only reads or writes Actor's own working state (a file or chart of the task, a memory
* A tool that only reads or writes Actor's own working state (a file or chart of the task, a memory
* note) returns null: reading the agent's own files never asks. The generic OpenVibe.Tools call is
* note) returns null: reading the agent's own files never asks. The generic OpenVibe.Tools call is
- * gated only when its input falls in an always-ask category, so a DNS lookup stays a lookup.
+ * gated only when the tool it runs is a consequential one, so a DNS lookup stays a lookup.
*
*
- * stepFor(name, input) → { act, category, summary, payload, recipientKnown? } | null
+ * stepFor(name, input) → { act, kind, category?, summary, target, payload, recipientKnown? } | null
*/
*/
const levels = require('./levels');
const levels = require('./levels');
+const kinds = require('./kinds');
const SECRET_KEYS = new Set(['password', 'passwd', 'passcode', 'secret', 'token', 'api_key', 'apikey', 'apiKey', 'access_token', 'refresh_token', 'private_key', 'card_number', 'cvv', 'cvc']);
const SECRET_KEYS = new Set(['password', 'passwd', 'passcode', 'secret', 'token', 'api_key', 'apikey', 'apiKey', 'access_token', 'refresh_token', 'private_key', 'card_number', 'cvv', 'cvc']);
const DELETE_CMD = /^(?:rm|rmdir|del|erase|shred|truncate|unlink|srm|rd)$/i;
const DELETE_CMD = /^(?:rm|rmdir|del|erase|shred|truncate|unlink|srm|rd)$/i;
@@ -45,16 +46,66 @@ function deletes(argv) {
@@ -45,16 +46,66 @@ function deletes(argv) {
return parts.some((a, i) => DELETE_CMD.test(a) || (i > 0 && /\b(?:rm|del|erase|shred|truncate|unlink)\b/i.test(a)));
return parts.some((a, i) => DELETE_CMD.test(a) || (i > 0 && /\b(?:rm|del|erase|shred|truncate|unlink)\b/i.test(a)));
}
}
+// HO1: a standing permission is matched on the step's target string, so a target names the step exactly: argv words
+// are quoted when they hold anything but plain characters (two different commands never read the same), nothing is
+// cut and no whitespace is folded. A step that cannot be named within allows.MAX_TARGET has no target, and so can
+// never be always allowed.
+const word = (w) => (/^[A-Za-z0-9_@%+=:,.\/-]+$/.test(w) ? w : JSON.stringify(w));
+const raw = (v) => (typeof v === 'string' ? v : '');
+const machineWord = (a) => (raw(a.machine) && a.machine !== 'any' ? word(a.machine) : 'your computer');
+const MAX_TARGET = 200;
+const exactTarget = (t) => (typeof t === 'string' && t.length > 0 && t.length <= MAX_TARGET && !/[\u0000-\u001f\u007f]/.test(t) ? t : null);
+function runTarget(a) {
+ if (!Array.isArray(a.argv) || !a.argv.length || !a.argv.every((x) => typeof x === 'string')) return null;
+ return exactTarget(`${a.argv.map(word).join(' ')}${raw(a.cwd) ? ` in ${word(a.cwd)}` : ''} on ${machineWord(a)}`);
+}
+// What repeats and when, both, exactly: allowing a schedule must not allow whatever task rides on it.
+function routineTarget(a) {
+ const what = raw(a.task) || raw(a.request);
+ const when = raw(a.cron) ? `cron ${word(a.cron)}${raw(a.timezone) ? ` ${word(a.timezone)}` : ''}`
+ : (a.event && raw(a.event.pattern) ? `when ${word(a.event.pattern)}` : '');
+ return what && when ? exactTarget(`${JSON.stringify(what)} ${when}`) : null;
+}
+/** The target of a coding task in one repository (the engine's own Codes gate uses it too). */
+const repoTarget = (repo) => (raw(repo) ? exactTarget(`repo ${word(repo)}`) : null);
+// Where a post or a publish lands, when the tool's input names it: the target is the tool and that place.
+const DESTINATION_KEYS = ['site', 'host', 'url', 'channel', 'room', 'space_id', 'blog', 'board', 'community'];
+function destinationOf(input) {
+ const o = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
+ for (const k of DESTINATION_KEYS) if (typeof o[k] === 'string' && o[k]) return `${k} ${word(o[k])}`;
+ return null;
+}
+
+// The kinds an OpenVibe.Tools tool may be, by its id. The first match wins, so the order is the order of
+// the trust: an install is an install, even when its name also says "post".
+const TOOL_KINDS = [
+ [/^(install|installer|connector|connect|skill|skills)$/, 'install'],
+ [/^(pay|payment|payments|purchase|buy|checkout|charge|refund|transfer|wallet|vibes|vibe|balance|invoice|subscription|money|spend|topup)$/, 'money'],
+ [/^(publish|publisher)$/, 'publish'],
+ [/^(send|sender|email|mail|message|dm|notify|sms|telegram|discord|whatsapp)$/, 'send'],
+ [/^(post|comment|reply|share)$/, 'post'],
+];
+
+/** The kind of an OpenVibe.Tools tool by its id, or null when the tool is a plain lookup. */
+function toolKind(tool) {
+ const words = String(tool || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
+ for (const [re, kind] of TOOL_KINDS) if (words.some((w) => re.test(w))) return kind;
+ return null;
+}
+
function stepFor(name, input) {
function stepFor(name, input) {
const a = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
const a = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
switch (name) {
switch (name) {
// ── A person's computer (the old runtime and the modular tools share these names) ──
// ── A person's computer (the old runtime and the modular tools share these names) ──
case 'computer_run': {
case 'computer_run': {
const argv = commandOf(a.argv);
const argv = commandOf(a.argv);
+ const line = argv.join(' ');
return {
return {
act: 'act',
act: 'act',
+ kind: 'computer.run',
...(deletes(argv) ? { category: 'delete' } : {}),
...(deletes(argv) ? { category: 'delete' } : {}),
- summary: `Run ${quoted(argv.join(' '), 220) || 'a program'} on your computer`,
+ summary: `Run ${quoted(line, 220) || 'a program'} on your computer`,
+ target: runTarget(a),
payload: safeInput({ machine: a.machine, argv, cwd: a.cwd, desk: a.desk }),
payload: safeInput({ machine: a.machine, argv, cwd: a.cwd, desk: a.desk }),
};
};
}
}
@@ -62,7 +113,9 @@ function stepFor(name, input) {
@@ -62,7 +113,9 @@ function stepFor(name, input) {
const path = text(a.path, 300);
const path = text(a.path, 300);
return {
return {
act: 'edit',
act: 'edit',
+ kind: 'computer.write',
summary: `Write ${quoted(path, 220) || 'a file'} on your computer (${text(a.mode, 20) || 'create'})`,
summary: `Write ${quoted(path, 220) || 'a file'} on your computer (${text(a.mode, 20) || 'create'})`,
+ target: raw(a.path) ? exactTarget(`${word(raw(a.mode) || 'create')} ${word(a.path)} on ${machineWord(a)}`) : null,
payload: safeInput({ machine: a.machine, path, mode: a.mode, text: a.text }, { removeText: true }),
payload: safeInput({ machine: a.machine, path, mode: a.mode, text: a.text }, { removeText: true }),
};
};
}
}
@@ -71,8 +124,11 @@ function stepFor(name, input) {
@@ -71,8 +124,11 @@ function stepFor(name, input) {
const password = PASSWORD_FIELD.test(text(a.keys, 80));
const password = PASSWORD_FIELD.test(text(a.keys, 80));
return {
return {
act: 'act',
act: 'act',
+ kind: 'computer.input',
...(password ? { category: 'password' } : {}),
...(password ? { category: 'password' } : {}),
summary: action === 'type' ? 'Type on your computer' : `Use ${action || 'the mouse or keyboard'} on your computer`,
summary: action === 'type' ? 'Type on your computer' : `Use ${action || 'the mouse or keyboard'} on your computer`,
+ // Typing and clicking reach anything on the screen, a terminal included: never always allowed.
+ target: null,
payload: safeInput({ machine: a.machine, action, x: a.x, y: a.y, keys: a.keys, text: a.text }, { removeText: action === 'type' }),
payload: safeInput({ machine: a.machine, action, x: a.x, y: a.y, keys: a.keys, text: a.text }, { removeText: action === 'type' }),
};
};
}
}
@@ -87,8 +143,11 @@ function stepFor(name, input) {
@@ -87,8 +143,11 @@ function stepFor(name, input) {
: `${text(a.action, 20) || 'Use'} the browser on your computer`;
: `${text(a.action, 20) || 'Use'} the browser on your computer`;
return {
return {
act: 'act',
act: 'act',
+ kind: 'computer.browser',
...(password ? { category: 'password' } : {}),
...(password ? { category: 'password' } : {}),
summary,
summary,
+ // Only opening one exact address may be always allowed: a click or a fill acts on whatever page is open.
+ target: action === 'open' && raw(a.url) ? exactTarget(`open ${word(a.url)} on ${machineWord(a)}`) : null,
payload: safeInput({ machine: a.machine, action, url: a.url, ref: a.ref, selector: a.selector, text: a.text, key: a.key, desk: a.desk }, { removeText: action === 'fill' }),
payload: safeInput({ machine: a.machine, action, url: a.url, ref: a.ref, selector: a.selector, text: a.text, key: a.key, desk: a.desk }, { removeText: action === 'fill' }),
};
};
}
}
@@ -97,45 +156,79 @@ function stepFor(name, input) {
@@ -97,45 +156,79 @@ function stepFor(name, input) {
if (action === 'status') return null;
if (action === 'status') return null;
return {
return {
act: 'act',
act: 'act',
+ kind: 'computer.desk',
summary: `${action === 'start' ? 'Start' : 'Stop'} your agent's desk${a.machine && a.machine !== 'any' ? ` on ${text(a.machine, 80)}` : ''}`,
summary: `${action === 'start' ? 'Start' : 'Stop'} your agent's desk${a.machine && a.machine !== 'any' ? ` on ${text(a.machine, 80)}` : ''}`,
+ target: exactTarget(`${action === 'start' ? 'start' : 'stop'} the desk on ${machineWord(a)}`),
payload: safeInput({ machine: a.machine, action, kind: a.kind, width: a.width, height: a.height }),
payload: safeInput({ machine: a.machine, action, kind: a.kind, width: a.width, height: a.height }),
};
};
}
}
case 'robot_steps':
case 'robot_steps':
return {
return {
act: 'act',
act: 'act',
+ kind: 'robot.move',
summary: `Move your robot ${text(a.robot, 80)}: ${quoted(commandOf(a.steps).join(', '), 220)}`,
summary: `Move your robot ${text(a.robot, 80)}: ${quoted(commandOf(a.steps).join(', '), 220)}`,
+ target: raw(a.robot) ? exactTarget(`robot ${word(a.robot)}`) : null,
payload: safeInput({ robot: a.robot, steps: a.steps }),
payload: safeInput({ robot: a.robot, steps: a.steps }),
};
};
// ── OpenVibe.Space: the person's own spaces ──
// ── OpenVibe.Space: the person's own spaces ──
case 'space_create':
case 'space_create':
- return { act: 'act', summary: `Create a space${a.name ? ` ${quoted(a.name, 80)}` : ''} on OpenVibe.Space`, payload: safeInput({ name: a.name, description: a.description }) };
+ return {
+ act: 'act',
+ kind: 'space',
+ summary: `Create a space${a.name ? ` ${quoted(a.name, 80)}` : ''} on OpenVibe.Space`,
+ target: raw(a.name) ? exactTarget(`new space ${word(a.name)}`) : null,
+ payload: safeInput({ name: a.name, description: a.description }),
+ };
case 'space_files_write':
case 'space_files_write':
return {
return {
act: 'edit',
act: 'edit',
+ kind: 'space',
summary: `Write ${Array.isArray(a.files) ? Math.min(a.files.length, 20) : 0} file(s) in your space ${text(a.space_id, 64)}`,
summary: `Write ${Array.isArray(a.files) ? Math.min(a.files.length, 20) : 0} file(s) in your space ${text(a.space_id, 64)}`,
+ target: raw(a.space_id) ? exactTarget(`files of space ${word(a.space_id)}`) : null,
payload: safeInput({ space_id: a.space_id, files: a.files }),
payload: safeInput({ space_id: a.space_id, files: a.files }),
};
};
case 'space_publish':
case 'space_publish':
- return { act: 'act', summary: `Publish your space ${text(a.space_id, 64)} on the open web`, payload: safeInput({ space_id: a.space_id }) };
+ return {
+ act: 'act',
+ kind: 'publish',
+ summary: `Publish your space ${text(a.space_id, 64)} on the open web`,
+ target: raw(a.space_id) ? exactTarget(`publish space ${word(a.space_id)}`) : null,
+ payload: safeInput({ space_id: a.space_id }),
+ };
// ── Coding on the person's machine ──
// ── Coding on the person's machine ──
case 'codes_task': {
case 'codes_task': {
const repo = text(a.repo, 300);
const repo = text(a.repo, 300);
return {
return {
act: 'edit',
act: 'edit',
+ kind: 'codes.task',
summary: `Start a coding task on your machine${repo ? ` in ${repo}` : ''}`,
summary: `Start a coding task on your machine${repo ? ` in ${repo}` : ''}`,
+ target: repoTarget(a.repo),
payload: safeInput({ instruction: a.instruction, repo }),
payload: safeInput({ instruction: a.instruction, repo }),
};
};
}
}
- // ── A generic OpenVibe.Tools tool: a lookup stays a lookup, a floor category asks ──
+ // ── A generic OpenVibe.Tools tool: a lookup stays a lookup, a consequential one asks ──
case 'run_tool': {
case 'run_tool': {
- const summary = `Run the OpenVibe.Tools tool ${text(a.tool, 80)}`;
+ const tool = text(a.tool, 80);
+ const summary = `Run the OpenVibe.Tools tool ${tool}`;
+ const kind = toolKind(tool);
const categories = levels.classify({ summary, payload: a.input });
const categories = levels.classify({ summary, payload: a.input });
- if (!categories.length) return null;
- return { act: 'act', category: categories[0], summary, payload: safeInput(a.input) };
+ if (!kind) {
+ // A tool whose name says nothing may still be a payment or a deletion: the floor decides.
+ if (!categories.length) return null;
+ return { act: 'act', kind: 'actor.action', category: categories[0], summary, target: null, payload: safeInput(a.input) };
+ }
+ // Spending is the floor whatever the words say. A post or a publish may be allowed for one tool and one
+ // place its input names; a send or an install never (kinds.NEVER_ALWAYS), so they carry no target.
+ const floorCategory = kind === 'money' ? 'money' : categories[0];
+ const dest = (kind === 'post' || kind === 'publish') ? destinationOf(a.input) : null;
+ return {
+ act: 'act', kind, ...(floorCategory ? { category: floorCategory } : {}), summary,
+ target: dest && raw(a.tool) ? exactTarget(`${word(a.tool)} to ${dest}`) : null,
+ payload: safeInput(a.input),
+ };
}
}
// ── A routine: it keeps running after this turn, so the card says what and when in plain words ──
// ── A routine: it keeps running after this turn, so the card says what and when in plain words ──
@@ -145,7 +238,10 @@ function stepFor(name, input) {
@@ -145,7 +238,10 @@ function stepFor(name, input) {
: (a.event && a.event.pattern ? `when ${text(a.event.pattern, 80)} happens` : '');
: (a.event && a.event.pattern ? `when ${text(a.event.pattern, 80)} happens` : '');
return {
return {
act: 'act',
act: 'act',
+ kind: 'routine',
summary: `Set up a task that repeats${when ? ` (${when})` : ''}: ${quoted(what, 160) || 'the one you asked for'}`,
summary: `Set up a task that repeats${when ? ` (${when})` : ''}: ${quoted(what, 160) || 'the one you asked for'}`,
+ // What repeats and when, both: allowing a schedule must not allow whatever task rides on it.
+ target: routineTarget(a),
payload: safeInput({ request: a.request, task: a.task, cron: a.cron, timezone: a.timezone, event: a.event, agent_id: a.agent_id }),
payload: safeInput({ request: a.request, task: a.task, cron: a.cron, timezone: a.timezone, event: a.event, agent_id: a.agent_id }),
};
};
}
}
@@ -160,8 +256,14 @@ function stepFor(name, input) {
@@ -160,8 +256,14 @@ function stepFor(name, input) {
// A connector (MCP/A2A) tool this Actor does not know: its acts:true says it may change something.
// A connector (MCP/A2A) tool this Actor does not know: its acts:true says it may change something.
default:
default:
- return { act: 'act', summary: `Use ${text(name, 80)}`, payload: safeInput(a) };
+ return {
+ act: 'act',
+ kind: 'actor.action',
+ summary: `Use ${text(name, 80)}`,
+ target: exactTarget(`tool ${word(String(name || ''))}`),
+ payload: safeInput(a),
+ };
}
}
}
}
-module.exports = { stepFor, deletes, safeInput, SECRET_KEYS, PASSWORD_FIELD };
+module.exports = { stepFor, deletes, safeInput, SECRET_KEYS, PASSWORD_FIELD, toolKind, TOOL_KINDS, repoTarget, runTarget };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+
+/**
+ * "Always allow this for this agent" (plan HO1): a standing permission the person gives for one exact kind of
+ * step, scoped by the kind and by the target it would touch ("publish to openvibe.blog", "run git status on
+ * dev_x"). It never covers the always-ask floor: money, a password, a deletion and a new recipient ask every
+ * time, whatever the person allowed before (levels.js).
+ *
+ * A rule is a row, not a flag: the person may revoke one, and the agent's page lists them.
+ *
+ * list(s, owner, agentId) → the active rules of one agent
+ * listAll(s, owner) → every active rule of the person
+ * grant(s, owner, agentId, kind, target) → the rule (made or found again)
+ * revoke(s, owner, id) → the rule, revoked
+ * matches(rules, kind, target) → true when a rule covers exactly this step
+ */
+
+const kinds = require('./kinds');
+
+const ID = /^alw_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_TARGET = 200;
+
+// A rule is matched on its target string, so the string must be the step exactly: never cut and never with its
+// whitespace folded (a cut target would also match a different, longer step that starts the same way). A target
+// that is too long, empty or carries a control character cannot be allowed at all (agents/actions.js builds them).
+const exact = (v) => (typeof v === 'string' && v.length > 0 && v.length <= MAX_TARGET && !/[\u0000-\u001f\u007f]/.test(v) ? v : null);
+
+/** The active rules of one agent (newest first). */
+async function list(s, owner, agentId) {
+ if (!agentId) return [];
+ return await s.db.many(`SELECT * FROM actor_approval_allows
+ WHERE owner = $1 AND agent_id = $2 AND revoked_at IS NULL ORDER BY created_at DESC`, [owner, agentId]);
+}
+
+/** Every active rule of the person (all their agents). */
+async function listAll(s, owner) {
+ return await s.db.many(`SELECT * FROM actor_approval_allows
+ WHERE owner = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 200`, [owner]);
+}
+
+/** Make the rule, or find the one that is already there. One rule per (owner, agent, kind, target). */
+async function grant(s, owner, agentId, kind, target) {
+ if (!kinds.allowable(kind)) return { error: 'This kind of step always asks, so it can never be always allowed.' };
+ const scope = exact(target);
+ if (!scope) return { error: 'This step cannot be named exactly enough to allow it always.' };
+ const existing = await s.db.maybe(`SELECT * FROM actor_approval_allows
+ WHERE owner = $1 AND agent_id = $2 AND kind = $3 AND target = $4 AND revoked_at IS NULL`, [owner, agentId, kind, scope]);
+ if (existing) return { row: existing, made: false };
+ const now = s.iso();
+ const row = await s.db.one(`INSERT INTO actor_approval_allows (id, owner, agent_id, kind, target, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT DO NOTHING
+ RETURNING *`, [s.newId('alw'), owner, agentId, kind, scope, now]);
+ if (row) return { row, made: true };
+ // The row was made and revoked in the same moment: the partial index lets it live again.
+ const again = await s.db.maybe(`SELECT * FROM actor_approval_allows
+ WHERE owner = $1 AND agent_id = $2 AND kind = $3 AND target = $4 AND revoked_at IS NULL`, [owner, agentId, kind, scope]);
+ return { row: again, made: false };
+}
+
+/** One rule of the person, by its id. */
+async function get(s, owner, id) {
+ if (!ID.test(String(id || ''))) return null;
+ return await s.db.maybe('SELECT * FROM actor_approval_allows WHERE id = $1 AND owner = $2', [id, owner]);
+}
+
+/** Revoke one rule of the person. */
+async function revoke(s, owner, id) {
+ if (!ID.test(String(id || ''))) return null;
+ return await s.db.maybe(`UPDATE actor_approval_allows SET revoked_at = $3
+ WHERE id = $1 AND owner = $2 AND revoked_at IS NULL RETURNING *`, [id, owner, s.iso()]);
+}
+
+/** Does a rule cover exactly this step? The kind and the target must both match. */
+function matches(rules, kind, target) {
+ if (!Array.isArray(rules) || !rules.length || !kinds.allowable(kind)) return false;
+ const scope = exact(target);
+ if (!scope) return false;
+ return rules.some((r) => r && r.kind === kind && r.target === scope);
+}
+
+module.exports = { ID, MAX_TARGET, exact, list, listAll, get, grant, revoke, matches };
@@ -1,33 +1,46 @@
@@ -1,33 +1,46 @@
'use strict';
'use strict';
/**
/**
- * The approval inbox (plan T17: "an approval inbox for consequential actions"). A person's own agent runs while they
- * are away (a schedule, a watch): when its work would act outside Actor on their behalf — today, a coding task on their
- * own machine through OpenVibe.Codes, or one of its acting tools (a send, a purchase, a deletion, a password field, a
- * computer_run, a browser action, a Space publish) — the run asks instead of acting. The request waits on /me/approvals;
- * approving a codes.task starts that work as the person's own task (they are present now), declining or letting it
- * expire (7 days) runs nothing. An actor.action approval records the person's yes for a retry: the run that asked had
- * already stopped, so nothing is replayed on its own. A person's direct task never asks: they are the one asking.
+ * The approval inbox (plan T17: "an approval inbox for consequential actions"; plan HO1: universal approvals).
+ * A person's own agent runs while they are away (a schedule, a watch): when its work would act outside Actor on
+ * their behalf — a coding task on their own machine through OpenVibe.Codes, or any of its acting tools (a send, a
+ * purchase, a deletion, a password field, a computer_run, a browser action, a Space publish) — the run asks instead
+ * of acting. The request waits on /me/approvals; approving a codes.task starts that work as the person's own task
+ * (they are present now), declining or letting it expire (7 days) runs nothing. An actor.action approval records the
+ * person's yes for a retry: the run that asked had already stopped, so nothing is replayed on its own. A person's
+ * direct task never asks: they are the one asking.
*
*
- * request(s, { owner, agentId, taskId, kind, summary, payload }) → the row (pending)
+ * Every request says what KIND of act it is (agents/kinds.js), in plain words, and which exact TARGET it would touch.
+ * The person may approve it once, or approve it and say "always allow this for this agent" (agents/allows.js): a
+ * standing permission for that kind and that target, revocable from the agent's page. The always-ask floor (money, a
+ * password, a deletion, a new recipient) is never covered by such a permission: it asks every time.
+ *
+ * request(s, { owner, agentId, taskId, kind, summary, payload, target, categories }) → the row (pending)
* list(s, owner) / get(s, owner, id) → rows, pending past their expiry marked expired
* list(s, owner) / get(s, owner, id) → rows, pending past their expiry marked expired
- * decide(ctx, owner, id, 'approve' | 'decline') → { row, task? } | { error, status }
+ * decide(ctx, owner, id, 'approve' | 'approve_always' | 'decline') → { row, task? } | { error, status }
* KINDS, EXPIRES_DAYS
* KINDS, EXPIRES_DAYS
*/
*/
const { createTask } = require('../tasks/create');
const { createTask } = require('../tasks/create');
+const levels = require('./levels');
+const kinds = require('./kinds');
+const allows = require('./allows');
-const KINDS = ['codes.task', 'actor.action'];
+const KINDS = kinds.KINDS;
const EXPIRES_DAYS = 7;
const EXPIRES_DAYS = 7;
const ID = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;
const ID = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;
-async function request(s, { owner, agentId = null, taskId = null, kind, summary, payload }) {
+/** The categories of a step, as a list of the floor's words (money, password, delete, new_recipient). */
+const categoryList = (value) => (Array.isArray(value) ? value.filter((c) => levels.ALWAYS_ASK.includes(c)) : []);
+
+async function request(s, { owner, agentId = null, taskId = null, kind, summary, payload, target = null, categories = null }) {
if (!KINDS.includes(kind)) throw new Error(`unknown approval kind ${kind}`);
if (!KINDS.includes(kind)) throw new Error(`unknown approval kind ${kind}`);
const id = s.newId('apr');
const id = s.newId('apr');
const now = s.iso();
const now = s.iso();
const expires = new Date(Date.parse(now) + EXPIRES_DAYS * 86400_000).toISOString();
const expires = new Date(Date.parse(now) + EXPIRES_DAYS * 86400_000).toISOString();
- return await s.db.one(`INSERT INTO actor_approvals (id, owner, agent_id, task_id, kind, summary, payload, state, created_at, expires_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, 'pending', $8, $9) RETURNING *`,
- [id, owner, agentId, taskId, kind, String(summary).slice(0, 500), JSON.stringify(payload), now, expires]);
+ return await s.db.one(`INSERT INTO actor_approvals (id, owner, agent_id, task_id, kind, summary, payload, target, categories, state, created_at, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9::jsonb, 'pending', $10, $11) RETURNING *`,
+ [id, owner, agentId, taskId, kind, String(summary).slice(0, 500), JSON.stringify(payload), allows.exact(target),
+ JSON.stringify(categoryList(categories)), now, expires]);
}
}
/** Pending requests past their expiry become expired as they are read. */
/** Pending requests past their expiry become expired as they are read. */
@@ -51,9 +64,28 @@ async function pendingCount(s, owner) {
@@ -51,9 +64,28 @@ async function pendingCount(s, owner) {
return Number(await s.db.value("SELECT count(*) FROM actor_approvals WHERE owner = $1 AND state = 'pending'", [owner]));
return Number(await s.db.value("SELECT count(*) FROM actor_approvals WHERE owner = $1 AND state = 'pending'", [owner]));
}
}
-/** The person decides. Approving a codes.task starts it as their own task, routed to the codes agent.
- * With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON
- * Action Center route); the HTML page keeps its 409. */
+/** The floor categories of a row: what it stored, or what its own words say (an old row has no column). */
+function floorOf(row) {
+ let stored = [];
+ try {
+ stored = categoryList(typeof row.categories === 'string' ? JSON.parse(row.categories || '[]') : row.categories);
+ } catch { stored = []; }
+ let payload = null;
+ try { payload = typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : row.payload; } catch { payload = null; }
+ // What the request stored, what its words say, and a command that deletes by its argv (rm, shred …).
+ const argvDeletes = payload && Array.isArray(payload.argv) && require('./actions').deletes(payload.argv);
+ const floor = new Set([...stored, ...levels.classify({ summary: row.summary, payload }), ...(argvDeletes ? ['delete'] : [])]);
+ if (row.kind === 'money') floor.add('money');
+ return [...floor];
+}
+
+/**
+ * The person decides. Approving a codes.task starts it as their own task, routed to the codes agent.
+ * `approve_always` is approving plus a standing permission for this exact kind and target of this agent; it is
+ * refused for a floor step (money, a password, a deletion, a new recipient), which asks every time.
+ * With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON
+ * Action Center route); the HTML page keeps its 409.
+ */
async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
const { s, config, engine, webhooks } = ctx;
const { s, config, engine, webhooks } = ctx;
const row = await get(s, owner, id);
const row = await get(s, owner, id);
@@ -63,23 +95,39 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
@@ -63,23 +95,39 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
return { error: `This request is already ${row.state}.`, status: 409 };
return { error: `This request is already ${row.state}.`, status: 409 };
}
}
if (decision === 'decline') {
if (decision === 'decline') {
- const done = await s.db.one("UPDATE actor_approvals SET state = 'declined', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);
+ const done = await s.db.one("UPDATE actor_approvals SET state = 'declined', decided_at = $2, decided_by = $3 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso(), owner]);
return { row: done };
return { row: done };
}
}
- if (decision !== 'approve') return { error: 'Approve or decline.', status: 422 };
- // An action had already stopped when it asked: a yes is consent for the person to run it again, never a replay
- // of a call whose arguments only exist in the old run's context.
- if (row.kind === 'actor.action') {
- const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);
- return { row: done, note: 'Approved. The run that asked had already stopped; run it again to let the agent go on.' };
+ if (decision !== 'approve' && decision !== 'approve_always') return { error: 'Approve or decline.', status: 422 };
+ const always = decision === 'approve_always';
+ if (always) {
+ const floored = floorOf(row);
+ if (floored.length) {
+ return { error: `This step always asks (${floored.join(', ')}), so it can never be always allowed.`, status: 422 };
+ }
+ if (!row.agent_id) return { error: 'This request has no agent to allow it for.', status: 422 };
+ if (!kinds.allowable(row.kind)) return { error: 'This kind of step always asks, so it can never be always allowed.', status: 422 };
+ if (!row.target) return { error: 'This step has no target to allow.', status: 422 };
+ }
+ // A coding task starts as the person's own task. Every other kind had already stopped when it asked: a yes is
+ // consent for the person to run it again, never a replay of a call whose arguments only exist in the old run.
+ if (row.kind !== 'codes.task') {
+ const allow = always ? await allows.grant(s, owner, row.agent_id, row.kind, row.target) : { row: null };
+ if (always && !allow.row) return { error: allow.error || 'This step could not be allowed.', status: 422 };
+ const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, decided_by = $3, decision_scope = $4, allow_id = $5 WHERE id = $1 AND state = 'pending' RETURNING *",
+ [id, s.iso(), owner, always ? 'always' : 'once', allow.row ? allow.row.id : null]);
+ return { row: done, note: 'Approved. The run that asked had already stopped; run it again to let the agent go on.', allow: allow.row ? allow.row.id : null };
}
}
const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
// The person just approved this exact work, so the thread starts at full access: the engine's approval gate must not
// The person just approved this exact work, so the thread starts at full access: the engine's approval gate must not
// ask again. The always-ask floor still applies (the text may still be a deletion or a payment).
// ask again. The always-ask floor still applies (the text may still be a deletion or a payment).
const out = await createTask({ s, config, engine, webhooks, principal: { requester: owner, via: null, project: null }, body: { task: payload.task, agent: 'codes', mode: 'balanced', level: 'full' } });
const out = await createTask({ s, config, engine, webhooks, principal: { requester: owner, via: null, project: null }, body: { task: payload.task, agent: 'codes', mode: 'balanced', level: 'full' } });
if (!out.task) return { error: out.detail || 'The task could not start.', status: out.status || 422 };
if (!out.task) return { error: out.detail || 'The task could not start.', status: out.status || 422 };
- const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, result_task_id = $3 WHERE id = $1 RETURNING *", [id, s.iso(), out.task.id]);
- return { row: done, task: out.task };
+ const allow = always ? await allows.grant(s, owner, row.agent_id, row.kind, row.target) : { row: null };
+ if (always && !allow.row) return { error: allow.error || 'This step could not be allowed.', status: 422 };
+ const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, decided_by = $3, decision_scope = $4, allow_id = $5, result_task_id = $6 WHERE id = $1 RETURNING *",
+ [id, s.iso(), owner, always ? 'always' : 'once', allow.row ? allow.row.id : null, out.task.id]);
+ return { row: done, task: out.task, allow: allow.row ? allow.row.id : null };
}
}
-module.exports = { request, list, get, pendingCount, decide, KINDS, EXPIRES_DAYS };
+module.exports = { request, list, get, pendingCount, decide, floorOf, KINDS, EXPIRES_DAYS };
@@ -26,7 +26,9 @@ async function lately(s, requester, { approvals = [], agents = [], limit = 10 }
@@ -26,7 +26,9 @@ async function lately(s, requester, { approvals = [], agents = [], limit = 10 }
for (const a of approvals) {
for (const a of approvals) {
events.push({ at: a.created_at, kind: 'asked', state: 'pending', text: `Asked for your approval: ${a.summary}`, href: `/me/approvals/${a.id}` });
events.push({ at: a.created_at, kind: 'asked', state: 'pending', text: `Asked for your approval: ${a.summary}`, href: `/me/approvals/${a.id}` });
if (a.decided_at && DECIDED[a.state]) {
if (a.decided_at && DECIDED[a.state]) {
- events.push({ at: a.decided_at, kind: 'decided', state: a.state, text: `${DECIDED[a.state]}: ${a.summary}`, href: `/me/approvals/${a.id}` });
+ // Once or always: the person can see which they chose (plan HO1).
+ const how = a.state === 'approved' ? (a.decision_scope === 'always' ? ' (and allowed it always for this agent)' : ' (once)') : '';
+ events.push({ at: a.decided_at, kind: 'decided', state: a.state, text: `${DECIDED[a.state]}${how}: ${a.summary}`, href: `/me/approvals/${a.id}` });
}
}
}
}
return events.sort((x, y) => (x.at < y.at ? 1 : x.at > y.at ? -1 : 0)).slice(0, limit);
return events.sort((x, y) => (x.at < y.at ? 1 : x.at > y.at ? -1 : 0)).slice(0, limit);
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+
+/**
+ * The kinds of act an approval can be about (plan HO1, "universal approvals": the trust spine). One list,
+ * so every acting tool says which kind it is (agents/actions.js) and the person reads one plain phrase for
+ * it — on the approval page, in the Action Center and in the agent's own page.
+ *
+ * The always-ask floor (money, a password, a deletion, a new recipient) is a different thing and lives in
+ * levels.js: it is a property of what the step says, not of its kind. A kind is what the act IS; a floor
+ * category is what the act would TOUCH. A floor step can never be always-allowed, whatever its kind.
+ */
+
+const KINDS = [
+ 'codes.task', // a coding task on the person's own machine
+ 'actor.action', // a connected tool (an MCP server or another agent) that acts
+ 'computer.run', // run a program on the person's computer
+ 'computer.write', // write a file on the person's computer
+ 'computer.input', // use the mouse or the keyboard on the person's computer
+ 'computer.browser',// open, fill or click in the person's browser
+ 'computer.desk', // start or stop the agent's desk on a machine
+ 'robot.move', // move the person's robot
+ 'space', // change the person's own OpenVibe.Space (make one, write its files)
+ 'publish', // publish something for the public to see
+ 'post', // post on an OpenVibe site
+ 'send', // send an email, a message or a file to someone
+ 'install', // install a skill or connect a connector
+ 'routine', // set up a task that keeps running
+ 'money', // spend the person's money
+];
+
+// The plain words a person reads for each kind. Never an id, never a code: this is the sentence on the card.
+const LABELS = {
+ 'codes.task': 'a coding task on your machine',
+ 'actor.action': 'an action by a connected tool',
+ 'computer.run': 'running a program on your computer',
+ 'computer.write': 'writing a file on your computer',
+ 'computer.input': 'using your mouse or keyboard',
+ 'computer.browser': 'acting in your browser',
+ 'computer.desk': "starting or stopping your agent's desk",
+ 'robot.move': 'moving your robot',
+ 'space': 'changing your own OpenVibe.Space',
+ 'publish': 'publishing for others to see',
+ 'post': 'posting on an OpenVibe site',
+ 'send': 'sending something to someone',
+ 'install': 'installing a skill or connecting a connector',
+ 'routine': 'setting up a task that keeps running',
+ 'money': 'spending your money',
+};
+
+// Kinds a person can never "always allow", whatever the target: spending money is the floor itself; typing reaches
+// anything a terminal can run (so one rule would cover every command); a send's recipient and an install's code are
+// not in the step's name, so no target can say exactly what was allowed.
+const NEVER_ALWAYS = ['money', 'computer.input', 'send', 'install'];
+
+const isKind = (kind) => KINDS.includes(String(kind || ''));
+const allowable = (kind) => isKind(kind) && !NEVER_ALWAYS.includes(String(kind));
+
+/** The plain phrase for a kind; an unknown kind reads as an action, never as an empty card. */
+const label = (kind) => LABELS[String(kind || '')] || 'an action';
+
+/** The phrase with the step's own target: "publishing for others to see · openvibe.blog". */
+const phrase = (kind, target) => {
+ const what = label(kind);
+ const where = String(target || '').trim();
+ return where ? `${what} · ${where}` : what;
+};
+
+module.exports = { KINDS, LABELS, NEVER_ALWAYS, isKind, allowable, label, phrase };
@@ -10,7 +10,11 @@
@@ -10,7 +10,11 @@
*
*
* LEVELS, RANK, maxLevel(...)
* LEVELS, RANK, maxLevel(...)
* classify({ summary, payload, category, recipientKnown }) → ['money' | 'password' | 'delete' | 'new_recipient']
* classify({ summary, payload, category, recipientKnown }) → ['money' | 'password' | 'delete' | 'new_recipient']
- * decide({ level, categories, act, models, summary, payload, signal }) → { ask, floor?, reason, reviewer? }
+ * decide({ level, categories, act, models, summary, payload, signal, allow }) → { ask, floor?, reason, reviewer? }
+ *
+ * `allow` is the person's standing permission for one exact step (plan HO1): { kind, target, rules }. A rule that
+ * covers this kind and this target lets the step go on at any level. The floor is checked first and is never
+ * covered by a rule: money, a password, a deletion and a new recipient ask every time.
*/
*/
const LEVELS = ['ask', 'auto-edits', 'approve-for-me', 'full'];
const LEVELS = ['ask', 'auto-edits', 'approve-for-me', 'full'];
@@ -76,9 +80,13 @@ const floorFor = (categories) => (Array.isArray(categories) ? categories : []).f
@@ -76,9 +80,13 @@ const floorFor = (categories) => (Array.isArray(categories) ? categories : []).f
* anything else for an outward action. Returns { ask, floor, reason } — and for approve-for-me whose reviewer approves,
* anything else for an outward action. Returns { ask, floor, reason } — and for approve-for-me whose reviewer approves,
* { ask: false, reviewer }. Never returns ask=false for a floor category.
* { ask: false, reviewer }. Never returns ask=false for a floor category.
*/
*/
-async function decide({ level = 'ask', categories = [], act = 'act', models = null, summary = '', payload = null, signal = null } = {}) {
+async function decide({ level = 'ask', categories = [], act = 'act', models = null, summary = '', payload = null, signal = null, allow = null } = {}) {
const floored = floorFor(categories);
const floored = floorFor(categories);
if (floored.length) return { ask: true, floor: floored[0], categories: floored, reason: `Always asks before ${floored.join(', ')} at every level.` };
if (floored.length) return { ask: true, floor: floored[0], categories: floored, reason: `Always asks before ${floored.join(', ')} at every level.` };
+ // The person's own standing permission for this exact step (plan HO1): it covers the level, never the floor.
+ if (allow && require('./allows').matches(allow.rules, allow.kind, allow.target)) {
+ return { ask: false, allowed: true, reason: 'You allowed this for this agent.' };
+ }
if (level === 'ask') return { ask: true, reason: 'This agent asks before it acts.' };
if (level === 'ask') return { ask: true, reason: 'This agent asks before it acts.' };
if (level === 'auto-edits' && (act === 'code' || act === 'edit')) return { ask: false, reason: 'Auto-accept edits.' };
if (level === 'auto-edits' && (act === 'code' || act === 'edit')) return { ask: false, reason: 'Auto-accept edits.' };
if (level === 'auto-edits') return { ask: true, reason: 'Auto-accept edits covers edits; other actions ask.' };
if (level === 'auto-edits') return { ask: true, reason: 'Auto-accept edits covers edits; other actions ask.' };
@@ -305,7 +305,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -305,7 +305,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
const step = stepFor(name, a);
const step = stepFor(name, a);
if (step) {
if (step) {
let ask;
let ask;
- try { ask = await requestApproval({ kind: 'actor.action', ...step }); }
+ try { ask = await requestApproval({ ...step, kind: step.kind || 'actor.action' }); }
catch (err) { return json({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }); }
catch (err) { return json({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }); }
if (ask && ask.pending) return json({ error: `not started: the person must approve it first at ${ask.url}`, approval: { id: ask.id, url: ask.url } });
if (ask && ask.pending) return json({ error: `not started: the person must approve it first at ${ask.url}`, approval: { id: ask.id, url: ask.url } });
}
}
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state'];
+const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -10,6 +10,8 @@ const contacts = require('../agents/contacts');
@@ -10,6 +10,8 @@ const contacts = require('../agents/contacts');
const ui = require('./agent-contacts-ui');
const ui = require('./agent-contacts-ui');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
const levels = require('../agents/levels');
const levels = require('../agents/levels');
+const allows = require('../agents/allows');
+const kinds = require('../agents/kinds');
/** "22 minutes ago", "in 6 days": a person reads this, the exact UTC instant stays in the <time> element's title. */
/** "22 minutes ago", "in 6 days": a person reads this, the exact UTC instant stays in the <time> element's title. */
function ago(iso, now = Date.now()) {
function ago(iso, now = Date.now()) {
@@ -154,8 +156,8 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}
@@ -154,8 +156,8 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}
if (!signed(req)) return login(req, res);
if (!signed(req)) return login(req, res);
const rows = await approvals.list(s, owner(req));
const rows = await approvals.list(s, owner(req));
page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>
page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>
-<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>
-${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">Asked ${time(a.created_at)}</span></li>`)}</ul>`
+<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. You may also say "always allow this for this agent": that covers this one kind of step, on this one target, until you revoke it. Spending money, a password, a deletion and a new recipient always ask. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>
+${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">${kinds.label(a.kind)} · Asked ${time(a.created_at)}</span></li>`)}</ul>`
: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}
: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}
<p><a href="/me/agents">Your agents</a></p>`);
<p><a href="/me/agents">Your agents</a></p>`);
});
});
@@ -167,24 +169,38 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
@@ -167,24 +169,38 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
// The agent by its name (a person's own agent: another person's is never looked up here).
// The agent by its name (a person's own agent: another person's is never looked up here).
const agent = a.agent_id ? await contacts.get(s, owner(req), a.agent_id).catch(() => null) : null;
const agent = a.agent_id ? await contacts.get(s, owner(req), a.agent_id).catch(() => null) : null;
const agentName = agent && agent.name ? agent.name : 'your agent';
const agentName = agent && agent.name ? agent.name : 'your agent';
- // The always-ask floor, said in plain words before anything technical.
- // A raw command deletes by its argv (rm, shred …), which words alone do not show: the same check stepFor uses.
- const argvDeletes = p && Array.isArray(p.argv) && require('../agents/actions').deletes(p.argv);
- const floor = [...new Set([...levels.classify({ summary: a.summary, payload: p }), ...(argvDeletes ? ['delete'] : [])])].filter((c) => CAUTION[c]);
- const cmd = a.kind === 'actor.action' ? commandLine(p) : null;
+ // The always-ask floor, said in plain words before anything technical: what the request stored, what its words
+ // say, and a raw command that deletes by its argv (rm, shred …), the same checks approvals.decide uses.
+ const floor = approvals.floorOf(a).filter((c) => CAUTION[c]);
+ const coding = a.kind === 'codes.task';
+ const cmd = coding ? null : commandLine(p);
const nowMs = Date.parse(s.iso()) || Date.now(); // the service's own clock (a test's clock in a test)
const nowMs = Date.parse(s.iso()) || Date.now(); // the service's own clock (a test's clock in a test)
const where = p && typeof p.machine === 'string' && p.machine ? p.machine : null;
const where = p && typeof p.machine === 'string' && p.machine ? p.machine : null;
+ // "Always allow" (plan HO1): this one kind of step on this one exact target, for this agent. Never a floor step.
+ const canAllow = a.state === 'pending' && !floor.length && Boolean(a.agent_id) && Boolean(a.target);
+ const allowButton = canAllow
+ ? html`<button type="submit" name="decision" value="approve_always" class="pa-btn">Always allow this for ${agentName}</button>`
+ : '';
+ const allowHint = a.state !== 'pending' ? ''
+ : floor.length ? html`<p class="pa-hint">This always asks, so it can never be always allowed.</p>`
+ : canAllow ? html`<p class="pa-hint">Always allowing covers exactly this, and nothing else: ${kinds.phrase(a.kind, a.target)}. Anything different still asks. You can take it back on <a href="/me/agents/${a.agent_id}#always">${agentName}'s page</a>.</p>`
+ : '';
+ const decided = a.state === 'approved'
+ ? (a.decision_scope === 'always' ? `You approved this and allowed it from now on for ${agentName}.` : 'You approved this once.')
+ : (a.state === 'declined' ? 'You declined this.' : 'Nobody answered this one in time.');
+ const buttons = a.state === 'pending'
+ ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${coding ? 'Approve and start it' : 'Approve for a retry'}</button> ${allowButton} <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>${allowHint}`
+ : html`<p class="pa-hint">${decided}${a.decided_at ? html` Decided ${whenAt(a.decided_at, nowMs)}.` : ''}</p>${a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`;
page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>
page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>
-<p>${approvalPill(a)} <span class="pa-when">Asked ${whenAt(a.created_at, nowMs)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">${agentName}</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}${a.state === 'pending' && a.expires_at ? html` · expires ${whenAt(a.expires_at, nowMs)}` : ''}</span></p>
+<p>${approvalPill(a)} <span class="pa-when">${kinds.label(a.kind)} · Asked ${whenAt(a.created_at, nowMs)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">${agentName}</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}${a.state === 'pending' && a.expires_at ? html` · expires ${whenAt(a.expires_at, nowMs)}` : ''}</span></p>
${floor.length ? html`<p class="pa-caution" role="note">${floor.map((c) => CAUTION[c]).join(' ')}</p>` : ''}
${floor.length ? html`<p class="pa-caution" role="note">${floor.map((c) => CAUTION[c]).join(' ')}</p>` : ''}
-${a.kind === 'actor.action'
+${!coding
? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>
? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>
${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}
${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}
<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`
<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`
: html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
: html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`}
<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`}
-${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${a.kind === 'actor.action' ? 'Approve for a retry' : 'Approve and start it'}</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`
- : a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`);
+${buttons}`);
});
});
r.post('/me/approvals/:id', async (req, res) => {
r.post('/me/approvals/:id', async (req, res) => {
if (!write(req, res)) return;
if (!write(req, res)) return;
@@ -201,6 +217,10 @@ ${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}
@@ -201,6 +217,10 @@ ${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}
const pendingSuggestions = suggestions.filter((p) => p.state === 'pending');
const pendingSuggestions = suggestions.filter((p) => p.state === 'pending');
const applied = suggestions.filter((p) => p.state === 'applied').slice(0, 5);
const applied = suggestions.filter((p) => p.state === 'applied').slice(0, 5);
const runs = await s.db.many('SELECT id, state, created_at, result, cost_usd, error_detail FROM tasks WHERE requester = $1 AND via = $2 ORDER BY id DESC LIMIT 20', [a.owner, `agent:${a.id}`]);
const runs = await s.db.many('SELECT id, state, created_at, result, cost_usd, error_detail FROM tasks WHERE requester = $1 AND via = $2 ORDER BY id DESC LIMIT 20', [a.owner, `agent:${a.id}`]);
+ // What this agent may always do (plan HO1), and every decision the person made about its asks.
+ const allowRules = await allows.list(s, a.owner, a.id);
+ const decisions = await s.db.many(`SELECT id, kind, summary, state, decision_scope, decided_by, decided_at, created_at
+ FROM actor_approvals WHERE owner = $1 AND agent_id = $2 ORDER BY created_at DESC LIMIT 20`, [a.owner, a.id]);
// Its Vibe chat (agents/vibe-deliver.js), once a run has gone there.
// Its Vibe chat (agents/vibe-deliver.js), once a run has gone there.
const vibeChat = a.send_to_vibe ? await s.db.maybe("SELECT chat_url FROM actor_vibe_deliveries WHERE agent_id = $1 AND owner = $2 AND state = 'sent' AND chat_url IS NOT NULL ORDER BY sent_at DESC LIMIT 1", [a.id, a.owner]) : null;
const vibeChat = a.send_to_vibe ? await s.db.maybe("SELECT chat_url FROM actor_vibe_deliveries WHERE agent_id = $1 AND owner = $2 AND state = 'sent' AND chat_url IS NOT NULL ORDER BY sent_at DESC LIMIT 1", [a.id, a.owner]) : null;
// The run journal: each run's first line (its answer, or why it did not finish) and what it cost.
// The run journal: each run's first line (its answer, or why it did not finish) and what it cost.
@@ -225,6 +245,13 @@ ${a.last_skip_reason ? html`<p class="notice" role="status">The last scheduled r
@@ -225,6 +245,13 @@ ${a.last_skip_reason ? html`<p class="notice" role="status">The last scheduled r
<p class="pa-actions"><a class="pa-btn" href="https://openvibe.watch/watches/new?template=event&agent_id=${a.id}">When something happens on OpenVibe</a> <a class="pa-btn" href="https://openvibe.watch/watches/new?template=page&agent_id=${a.id}">When a page changes</a></p></section>
<p class="pa-actions"><a class="pa-btn" href="https://openvibe.watch/watches/new?template=event&agent_id=${a.id}">When something happens on OpenVibe</a> <a class="pa-btn" href="https://openvibe.watch/watches/new?template=page&agent_id=${a.id}">When a page changes</a></p></section>
<section class="pa-panel"><h2>Recent runs</h2>${vibeChat && /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/.test(vibeChat.chat_url) ? html`<p class="pa-hint">Each run also lands in <a href="${vibeChat.chat_url}">its Vibe chat</a>, where you can ask Vibe about it.</p>` : ''}${runs.length ? html`<ul class="pa-runs">${runs.map((t) => html`<li><a href="/tasks/${t.id}">${time(t.created_at)}</a> <span class="pill ${pill(t.state)}">${t.state}</span>${usd(t.cost_usd)}${gist(t) ? html`<span class="pa-run-gist">${gist(t)}</span>` : ''}</li>`)}</ul>` : html`<p class="muted">No run yet.</p>`}</section>
<section class="pa-panel"><h2>Recent runs</h2>${vibeChat && /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/.test(vibeChat.chat_url) ? html`<p class="pa-hint">Each run also lands in <a href="${vibeChat.chat_url}">its Vibe chat</a>, where you can ask Vibe about it.</p>` : ''}${runs.length ? html`<ul class="pa-runs">${runs.map((t) => html`<li><a href="/tasks/${t.id}">${time(t.created_at)}</a> <span class="pill ${pill(t.state)}">${t.state}</span>${usd(t.cost_usd)}${gist(t) ? html`<span class="pa-run-gist">${gist(t)}</span>` : ''}</li>`)}</ul>` : html`<p class="muted">No run yet.</p>`}</section>
</div>
</div>
+<section class="pa-panel" id="always" aria-labelledby="pa-always-h"><h2 id="pa-always-h">What it may always do</h2>
+<p class="pa-hint">When you said "always allow this for this agent", the permission is here: one kind of step, on one target. Take any of them back and the next one asks again. Spending money, a password, a deletion and a new recipient always ask, whatever is allowed here.</p>
+${allowRules.length ? html`<ul class="pa-notes">${allowRules.map((r) => html`<li><p class="pa-note-text">${kinds.phrase(r.kind, r.target)}</p><p class="pa-note-meta">Allowed ${time(r.created_at)}</p>
+<form method="post" action="/me/agents/${a.id}/allows/${r.id}/revoke" class="pa-actions"><button type="submit" class="pa-danger-link">Revoke</button></form></li>`)}</ul>` : html`<p class="muted">Nothing is always allowed for this agent.</p>`}</section>
+<section class="pa-panel" id="decisions" aria-labelledby="pa-dec-h"><h2 id="pa-dec-h">What you decided</h2>
+<p class="pa-hint">Every approval this agent asked for, and what you answered: once, always, or no.</p>
+${decisions.length ? html`<ul class="pa-notes">${decisions.map((d) => html`<li><p class="pa-note-text"><a href="/me/approvals/${d.id}">${d.summary}</a></p><p class="pa-note-meta">${kinds.label(d.kind)} · ${d.state === 'approved' ? (d.decision_scope === 'always' ? 'You approved it and allowed it always' : 'You approved it once') : d.state === 'declined' ? 'You declined it' : d.state === 'expired' ? 'It expired, nobody answered' : 'Waiting for you'} · ${time(d.decided_at || d.created_at)}</p></li>`)}</ul>` : html`<p class="muted">This agent has not asked for anything yet.</p>`}</section>
${pendingSuggestions.length || applied.length ? html`<section class="pa-panel" aria-labelledby="pa-sugg-h"><h2 id="pa-sugg-h">Suggestions from your agent</h2>
${pendingSuggestions.length || applied.length ? html`<section class="pa-panel" aria-labelledby="pa-sugg-h"><h2 id="pa-sugg-h">Suggestions from your agent</h2>
<p class="pa-hint">After a run that passed its check, the agent may suggest a line for its instructions. Nothing changes until you add it, and you can undo it.</p>
<p class="pa-hint">After a run that passed its check, the agent may suggest a line for its instructions. Nothing changes until you add it, and you can undo it.</p>
${pendingSuggestions.length ? html`<ul class="pa-notes">${pendingSuggestions.map((p) => html`<li><p class="pa-note-text">${p.text}</p><p class="pa-note-meta">Suggested ${time(p.created_at)}${p.from_task ? html` after <a href="/tasks/${p.from_task}">this run</a>` : ''}</p>
${pendingSuggestions.length ? html`<ul class="pa-notes">${pendingSuggestions.map((p) => html`<li><p class="pa-note-text">${p.text}</p><p class="pa-note-meta">Suggested ${time(p.created_at)}${p.from_task ? html` after <a href="/tasks/${p.from_task}">this run</a>` : ''}</p>
@@ -264,6 +291,15 @@ ${notes.length ? html`<p><a href="/memory">See all your memory</a> · <a href="/
@@ -264,6 +291,15 @@ ${notes.length ? html`<p><a href="/memory">See all your memory</a> · <a href="/
r.post('/me/agents/:id/memory/:mem/delete', async (req, res) => { if (!write(req, res)) return; const a = await find(req, res); if (!a) return; await s.db.query('DELETE FROM actor_agent_memory WHERE id = $1 AND agent_id = $2 AND owner = $3', [req.params.mem, a.id, a.owner]); res.redirect(303, `/me/agents/${a.id}`); });
r.post('/me/agents/:id/memory/:mem/delete', async (req, res) => { if (!write(req, res)) return; const a = await find(req, res); if (!a) return; await s.db.query('DELETE FROM actor_agent_memory WHERE id = $1 AND agent_id = $2 AND owner = $3', [req.params.mem, a.id, a.owner]); res.redirect(303, `/me/agents/${a.id}`); });
r.get('/me/agents/:id/forget', async (req, res) => { if (!signed(req)) return login(req, res); const a = await find(req, res); if (!a) return; page(req, res, 'Forget memory', html`<h1>Forget all memory for ${a.name}?</h1><form method="post" action="/me/agents/${a.id}/forget"><button type="submit">Forget everything</button></form><p><a href="/me/agents/${a.id}">Cancel</a></p>`); });
r.get('/me/agents/:id/forget', async (req, res) => { if (!signed(req)) return login(req, res); const a = await find(req, res); if (!a) return; page(req, res, 'Forget memory', html`<h1>Forget all memory for ${a.name}?</h1><form method="post" action="/me/agents/${a.id}/forget"><button type="submit">Forget everything</button></form><p><a href="/me/agents/${a.id}">Cancel</a></p>`); });
r.post('/me/agents/:id/forget', async (req, res) => { if (!write(req, res)) return; const a = await find(req, res); if (!a) return; await s.db.query('DELETE FROM actor_agent_memory WHERE agent_id = $1 AND owner = $2', [a.id, a.owner]); res.redirect(303, `/me/agents/${a.id}`); });
r.post('/me/agents/:id/forget', async (req, res) => { if (!write(req, res)) return; const a = await find(req, res); if (!a) return; await s.db.query('DELETE FROM actor_agent_memory WHERE agent_id = $1 AND owner = $2', [a.id, a.owner]); res.redirect(303, `/me/agents/${a.id}`); });
+ // Take back a standing permission (plan HO1): the next identical step asks again.
+ r.post('/me/agents/:id/allows/:allowId/revoke', async (req, res) => {
+ if (!write(req, res)) return;
+ const a = await find(req, res); if (!a) return;
+ const rule = await allows.get(s, a.owner, req.params.allowId);
+ if (!rule || rule.agent_id !== a.id) return page(req, res, 'Not found', html`<h1>Not found</h1><p><a href="/me/agents/${a.id}">Back to the agent</a></p>`, 404);
+ await allows.revoke(s, a.owner, req.params.allowId);
+ res.redirect(303, `/me/agents/${a.id}#always`);
+ });
return r;
return r;
}
}
@@ -95,9 +95,20 @@ function createApi(ctx) {
@@ -95,9 +95,20 @@ function createApi(ctx) {
lastEvent: store.parseJson(row.last_event), steps: Number(row.tool_steps),
lastEvent: store.parseJson(row.last_event), steps: Number(row.tool_steps),
wait: waitFor.get(row.id) || null, now: s.now,
wait: waitFor.get(row.id) || null, now: s.now,
}));
}));
- const asks = (await approvals.list(s, requester))
- .filter((a) => a.state === 'pending')
- .map(items.approvalItem);
+ // A waiting approval reaches the Action Center as an item a person can read: the kind in plain words, the
+ // agent's own name (never its id) and the thread it came from (plan HO1).
+ const pending = (await approvals.list(s, requester)).filter((a) => a.state === 'pending');
+ const agents = await s.db.many('SELECT id, name FROM actor_agents WHERE owner = $1', [requester]);
+ const agentName = new Map(agents.map((a) => [a.id, a.name]));
+ const threadIds = pending.map((a) => a.task_id).filter(Boolean);
+ const threads = threadIds.length
+ ? await s.db.many('SELECT id, task FROM tasks WHERE id = ANY($1)', [threadIds])
+ : [];
+ const threadTitle = new Map(threads.map((t) => [t.id, items.plain(t.task, 160)]));
+ const asks = pending.map((a) => items.approvalItem(a, {
+ agentName: a.agent_id ? agentName.get(a.agent_id) || null : null,
+ thread: a.task_id ? threadTitle.get(a.task_id) || null : null,
+ }));
const scheduled = (await personal.list(s, requester))
const scheduled = (await personal.list(s, requester))
.filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at)
.filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at)
.map(items.scheduledItem);
.map(items.scheduledItem);
@@ -115,7 +126,8 @@ function createApi(ctx) {
@@ -115,7 +126,8 @@ function createApi(ctx) {
if (!['approve', 'decline'].includes(decision)) return problem(req, res, 422, 'actor.approval.invalid', 'decision must be approve or decline');
if (!['approve', 'decline'].includes(decision)) return problem(req, res, 422, 'actor.approval.invalid', 'decision must be approve or decline');
const out = await approvals.decide(ctx, `user:${subject}`, req.params.id, decision, { idempotent: true });
const out = await approvals.decide(ctx, `user:${subject}`, req.params.id, decision, { idempotent: true });
if (out.error) return problem(req, res, out.status, out.status === 404 ? 'actor.approval.not_found' : 'actor.approval.invalid', out.error);
if (out.error) return problem(req, res, out.status, out.status === 404 ? 'actor.approval.not_found' : 'actor.approval.invalid', out.error);
- res.json(items.approvalItem(out.row));
+ const named = out.row && out.row.agent_id ? await s.db.maybe('SELECT name FROM actor_agents WHERE id = $1', [out.row.agent_id]) : null;
+ res.json(items.approvalItem(out.row, { agentName: named ? named.name : null }));
});
});
// ── The catalog, the contacts and the dry run ────────────
// ── The catalog, the contacts and the dry run ────────────
@@ -71,6 +71,8 @@ const TABLES = [
@@ -71,6 +71,8 @@ const TABLES = [
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
+ // The standing permissions the person gave their agents (agents/allows.js): what they always allow, and when.
+ { table: 'actor_approval_allows', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approval-allows.json' },
// The agent's timeline (design 2026-11-07): the steps the agent took for this person, and whether they paused
// The agent's timeline (design 2026-11-07): the steps the agent took for this person, and whether they paused
// it. Titles, outcomes and links only: there is no column for a prompt or a diff, so none can be exported.
// it. Titles, outcomes and links only: there is no column for a prompt or a diff, so none can be exported.
{ table: 'agent_timeline', subject: 'subject', value: (usr) => `user:${usr}`, file: 'timeline.json',
{ table: 'agent_timeline', subject: 'subject', value: (usr) => `user:${usr}`, file: 'timeline.json',
@@ -162,7 +162,8 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchI
@@ -162,7 +162,8 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchI
if (step) {
if (step) {
let ask;
let ask;
try {
try {
- ask = await ctx.requestApproval({ kind: 'actor.action', ...step });
+ // The step says which kind of act it is (plan HO1); an unknown tool is an action of a connected tool.
+ ask = await ctx.requestApproval({ ...step, kind: step.kind || 'actor.action' });
} catch (err) {
} catch (err) {
return normalize({ text: JSON.stringify({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }), isError: true });
return normalize({ text: JSON.stringify({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }), isError: true });
}
}
@@ -29,6 +29,7 @@ const { route, whyNone } = require('../agents/router');
@@ -29,6 +29,7 @@ const { route, whyNone } = require('../agents/router');
const { check } = require('../agents/check');
const { check } = require('../agents/check');
const { check: checkClaims } = require('../runtime/check');
const { check: checkClaims } = require('../runtime/check');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
+const allows = require('../agents/allows');
const contacts = require('../agents/contacts');
const contacts = require('../agents/contacts');
const assignments = require('../agents/assignments');
const assignments = require('../agents/assignments');
const runTokens = require('../triggers/tokens');
const runTokens = require('../triggers/tokens');
@@ -333,11 +334,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -333,11 +334,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
let closeAssignment = null;
let closeAssignment = null;
// One place every approval gate asks through: the level, then the always-ask floor, then (for approve-for-me)
// One place every approval gate asks through: the level, then the always-ask floor, then (for approve-for-me)
// a reviewer model of another family. Returns { asked: false } when the step may go ahead.
// a reviewer model of another family. Returns { asked: false } when the step may go ahead.
- const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT }) => {
+ // A standing permission the person gave for this exact kind and target (plan HO1) is read here: it lets the
+ // step go on, and it never covers the floor.
+ const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT, target = null }) => {
const categories = levels.classify({ category, summary, payload, recipientKnown });
const categories = levels.classify({ category, summary, payload, recipientKnown });
- const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal });
+ const rules = taskAgent ? await allows.list(s, row.requester, taskAgent.id) : [];
+ const decision = await levels.decide({ level: taskLevel, categories, act, models, summary, payload, signal, allow: { kind, target, rules } });
if (!decision.ask) return { asked: false, decision };
if (!decision.ask) return { asked: false, decision };
- const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary, payload });
+ const ask = await approvals.request(s, { owner: row.requester, agentId: taskAgent ? taskAgent.id : null, taskId: id, kind, summary, payload, target, categories });
const url = `${config.baseUrl}/me/approvals/${ask.id}`;
const url = `${config.baseUrl}/me/approvals/${ask.id}`;
await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(summary).toLowerCase()} Nothing runs until you approve it: ${url}` });
await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(summary).toLowerCase()} Nothing runs until you approve it: ${url}` });
await tellPerson(row, 'needs_you', { what: `approve: ${summary}`, url });
await tellPerson(row, 'needs_you', { what: `approve: ${summary}`, url });
@@ -426,8 +430,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -426,8 +430,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// A task that would start coding work on the person's machine asks through the same gate (levels.js):
// A task that would start coding work on the person's machine asks through the same gate (levels.js):
// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a
// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a
// payment, password, deletion or a new recipient never does.
// payment, password, deletion or a new recipient never does.
- requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null }) => {
- const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown });
+ requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, target = null }) => {
+ const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown, target });
if (!out.asked) return null;
if (!out.asked) return null;
return { pending: true, id: out.ask.id, url: out.url };
return { pending: true, id: out.ask.id, url: out.url };
} : null;
} : null;
@@ -654,7 +658,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -654,7 +658,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (agentId === 'codes' && taskAgent && codesRepoIn(row.task)) {
if (agentId === 'codes' && taskAgent && codesRepoIn(row.task)) {
const asked = await askForApproval({
const asked = await askForApproval({
kind: 'codes.task', summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`,
kind: 'codes.task', summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`,
- payload: { task: row.task }, act: 'edit', agent: agentId,
+ payload: { task: row.task }, act: 'edit', agent: agentId, target: require('../agents/actions').repoTarget(codesRepoIn(row.task)),
});
});
if (asked.asked) {
if (asked.asked) {
const result = {
const result = {
@@ -72,6 +72,7 @@ const DAY = 86_400_000;
@@ -72,6 +72,7 @@ const DAY = 86_400_000;
const declined = `apr_${ids.ulid()}`;
const declined = `apr_${ids.ulid()}`;
const oldApproval = `apr_${ids.ulid()}`;
const oldApproval = `apr_${ids.ulid()}`;
const theirsApproval = `apr_${ids.ulid()}`;
const theirsApproval = `apr_${ids.ulid()}`;
+ const thread = ids.newId('task');
const agent = ids.newId('agent');
const agent = ids.newId('agent');
const paused = ids.newId('agent');
const paused = ids.newId('agent');
const off = ids.newId('agent');
const off = ids.newId('agent');
@@ -84,9 +85,11 @@ const DAY = 86_400_000;
@@ -84,9 +85,11 @@ const DAY = 86_400_000;
await addTask({ id: cancelled, state: 'cancelled', text: 'Stopped task', created: now - 5 * DAY, finished: now - 4 * DAY });
await addTask({ id: cancelled, state: 'cancelled', text: 'Stopped task', created: now - 5 * DAY, finished: now - 4 * DAY });
await addTask({ id: old, state: 'succeeded', text: 'Ancient', created: now - 45 * DAY, finished: now - 40 * DAY });
await addTask({ id: old, state: 'succeeded', text: 'Ancient', created: now - 45 * DAY, finished: now - 40 * DAY });
await addTask({ id: theirs, requester: `user:${bo.subject}`, state: 'running', text: 'Bo\'s task' });
await addTask({ id: theirs, requester: `user:${bo.subject}`, state: 'running', text: 'Bo\'s task' });
+ // The thread the approval came from (plan HO1): its own words are what the hub shows.
+ await addTask({ id: thread, state: 'running', text: 'Fix the failing test in the repository' });
- await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
- VALUES ($1,$2,$3,'codes.task','Approve the fix', $4::jsonb,'pending',$5,$6,NULL)`, [approval, `user:${ada.subject}`, agent, JSON.stringify({ task: 'Fix the **bug** in the repository' }), iso(now - 3600_000), iso(now + 6 * DAY)]);
+ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, task_id, kind, summary, payload, state, created_at, expires_at, decided_at)
+ VALUES ($1,$2,$3,$4,'codes.task','Approve the fix', $5::jsonb,'pending',$6,$7,NULL)`, [approval, `user:${ada.subject}`, agent, thread, JSON.stringify({ task: 'Fix the **bug** in the repository' }), iso(now - 3600_000), iso(now + 6 * DAY)]);
await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
VALUES ($1,$2,$3,'codes.task','Already declined', '{}'::jsonb,'declined',$4,$5,$4)`, [declined, `user:${ada.subject}`, agent, iso(now - 2 * DAY), iso(now + 5 * DAY)]);
VALUES ($1,$2,$3,'codes.task','Already declined', '{}'::jsonb,'declined',$4,$5,$4)`, [declined, `user:${ada.subject}`, agent, iso(now - 2 * DAY), iso(now + 5 * DAY)]);
await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
@@ -116,7 +119,10 @@ const DAY = 86_400_000;
@@ -116,7 +119,10 @@ const DAY = 86_400_000;
assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);
assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);
assert.ok(byId.has(approval) && byId.get(approval).bucket === 'waiting', 'the pending approval waits');
assert.ok(byId.has(approval) && byId.get(approval).bucket === 'waiting', 'the pending approval waits');
assert.deepStrictEqual(byId.get(approval).actions, ['approve', 'decline', 'open']);
assert.deepStrictEqual(byId.get(approval).actions, ['approve', 'decline', 'open']);
- assert.strictEqual(byId.get(approval).summary, 'Fix the bug in the repository');
+ // The kind in plain words, and the agent by its name (never its raw id).
+ assert.strictEqual(byId.get(approval).title, 'Approve the fix');
+ assert.strictEqual(byId.get(approval).summary, 'a coding task on your machine · Fix the failing test in the repository');
+ assert.strictEqual(byId.get(approval).agent, 'Morning report');
assert.ok(!byId.has(declined), 'a decided approval is no longer an action waiting on the person');
assert.ok(!byId.has(declined), 'a decided approval is no longer an action waiting on the person');
assert.deepStrictEqual([byId.get(agent).bucket, byId.get(agent).state, byId.get(agent).next_run_at], ['scheduled', 'scheduled', iso(now + 5 * 3600_000)]);
assert.deepStrictEqual([byId.get(agent).bucket, byId.get(agent).state, byId.get(agent).next_run_at], ['scheduled', 'scheduled', iso(now + 5 * 3600_000)]);
assert.ok(!byId.has(paused) && !byId.has(off), 'paused and off agents are not scheduled');
assert.ok(!byId.has(paused) && !byId.has(off), 'paused and off agents are not scheduled');
new file mode 100644
new file mode 100644
@@ -0,0 +1,316 @@
@@ -0,0 +1,316 @@
+'use strict';
+/**
+ * HO1, universal approvals: the trust spine.
+ *
+ * Every acting tool says which kind of act it is (agents/actions.js, agents/kinds.js) and a plain summary of it;
+ * every pending approval reaches the Action Center as a waiting item with the kind, the agent's NAME and its own
+ * page; the person may approve once, or approve and allow that exact kind of step for that agent (scoped by kind
+ * and target, revocable); the always-ask floor (money, a password, a deletion, a new recipient) is never covered by
+ * such a permission; and every decision is in the agent's own activity.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+const { createRegistry } = require('../server/runtime/tools');
+const { stepFor } = require('../server/agents/actions');
+const kinds = require('../server/agents/kinds');
+const levels = require('../server/agents/levels');
+const approvals = require('../server/agents/approvals');
+const allows = require('../server/agents/allows');
+const contacts = require('../server/agents/contacts');
+const items = require('../server/actions/items');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+// Every acting tool and the kind it is. `null` means Actor's own working state: it never asks.
+const EXPECTED = {
+ codes_task: 'codes.task',
+ computer_browser: 'computer.browser',
+ computer_desk: 'computer.desk',
+ computer_input: 'computer.input',
+ computer_run: 'computer.run',
+ computer_write: 'computer.write',
+ hand_to_person: null,
+ make_chart: null,
+ make_file: null,
+ remember: null,
+ robot_steps: 'robot.move',
+ run_tool: 'by-tool',
+ schedule_trigger: 'routine',
+ space_create: 'space',
+ space_files_write: 'space',
+ space_publish: 'publish',
+};
+
+const SAMPLE = {
+ codes_task: { repo: 'ada/site', instruction: 'fix the bug' },
+ computer_browser: { machine: 'dev_x', action: 'click', text: 'Send' },
+ computer_desk: { machine: 'dev_x', action: 'start' },
+ computer_input: { machine: 'dev_x', action: 'type', text: 'hello' },
+ computer_run: { machine: 'dev_x', argv: ['git', 'status'] },
+ computer_write: { machine: 'dev_x', path: '/tmp/a.txt', text: 'hi' },
+ hand_to_person: { text: 'look at this' },
+ make_chart: { name: 'c' },
+ make_file: { name: 'n.md', text: 'hi' },
+ remember: { text: 'note' },
+ robot_steps: { robot: 'r1', steps: ['forward'] },
+ run_tool: { tool: 'openvibe.blog.post', input: { site: 'openvibe.blog' } },
+ schedule_trigger: { cron: '0 9 * * *', task: 'digest' },
+ space_create: { name: 'My space' },
+ space_files_write: { space_id: 'sp_1', files: [{ path: 'a.txt', content: 'x' }] },
+ space_publish: { space_id: 'sp_1' },
+};
+
+(async () => {
+ // ── Every acting tool has a kind ──
+ await check('every acting tool is mapped to a kind, and every kind has plain words', async () => {
+ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
+ const acting = registry.tools.filter((t) => t.acts === true).map((t) => t.name).sort();
+ assert.deepStrictEqual(acting, Object.keys(EXPECTED).sort(), 'every acting tool is in the list; classify a new one');
+ for (const name of acting) {
+ const step = stepFor(name, SAMPLE[name]);
+ if (EXPECTED[name] === null) {
+ assert.strictEqual(step, null, `${name}: Actor's own working state never asks`);
+ continue;
+ }
+ assert.ok(step, `${name}: the step must say what it is`);
+ assert.ok(kinds.isKind(step.kind), `${name}: ${step.kind} is a known kind`);
+ assert.ok(kinds.label(step.kind).length > 3, `${name}: the kind reads as plain words`);
+ assert.ok(step.summary && step.summary.length > 3, `${name}: a plain summary`);
+ // A target names the step exactly (it is what a standing permission matches), or is null: then the step
+ // can never be always allowed (typing, a click on whatever page is open, a send, an install, money).
+ assert.ok(step.target === null || (typeof step.target === 'string' && step.target.length > 1 && step.target.length <= allows.MAX_TARGET),
+ `${name}: an exact target or none`);
+ }
+ assert.strictEqual(stepFor('computer_run', SAMPLE.computer_run).target, 'git status on dev_x');
+ assert.strictEqual(stepFor('computer_input', SAMPLE.computer_input).target, null, 'typing is never always allowed');
+ assert.strictEqual(stepFor('computer_browser', SAMPLE.computer_browser).target, null, 'a click acts on whatever page is open');
+ assert.strictEqual(stepFor('computer_browser', { machine: 'dev_x', action: 'open', url: 'https://openvibe.blog/' }).target, 'open https://openvibe.blog/ on dev_x');
+ });
+
+ await check('a target names the step exactly: never cut, never folded, two commands never read the same', async () => {
+ const run = (argv, extra = {}) => stepFor('computer_run', { machine: 'dev_x', argv, ...extra }).target;
+ // Quoting: one word with a space is not two words.
+ assert.notStrictEqual(run(['echo', 'a b']), run(['echo', 'a', 'b']));
+ assert.strictEqual(run(['echo', 'a b']), 'echo "a b" on dev_x');
+ // Whitespace is never folded, and a newline is quoted, never a separator.
+ assert.notStrictEqual(run(['sh', '-c', 'make test']), run(['sh', '-c', 'make test']));
+ assert.strictEqual(run(['sh', '-c', 'make\nrm -rf ~']), 'sh -c "make\\nrm -rf ~" on dev_x');
+ // The working directory is part of the step.
+ assert.notStrictEqual(run(['make'], { cwd: '/a' }), run(['make'], { cwd: '/b' }));
+ // A command too long to name exactly has no target: it can never be always allowed (a cut target would also
+ // match any longer command that starts the same way).
+ const long = ['python', 'job.py', ...Array.from({ length: 40 }, (_, i) => `--flag-${i}`)];
+ assert.strictEqual(run(long), null);
+ assert.strictEqual(allows.exact('x'.repeat(allows.MAX_TARGET + 1)), null);
+ assert.strictEqual(allows.exact('git status\u0000 on dev_x'), null, 'no control characters');
+ assert.strictEqual(allows.matches([{ kind: 'computer.run', target: 'x'.repeat(200) }], 'computer.run', 'x'.repeat(201)), false);
+ // A machine the agent names is quoted too, so it cannot pose as part of the command.
+ assert.strictEqual(stepFor('computer_run', { machine: 'dev_x foo', argv: ['ls'] }).target, 'ls on "dev_x foo"');
+ // A routine names what repeats and when: allowing a schedule never allows another task on it.
+ const daily = (task) => stepFor('schedule_trigger', { cron: '0 9 * * *', task }).target;
+ assert.notStrictEqual(daily('digest'), daily('wire the money'));
+ assert.match(daily('digest'), /^"digest" cron "0 9 \* \* \*"$/);
+ // OpenVibe.Tools: a post is allowed for one tool and one place; a send, an install and money never.
+ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.blog.post', input: { site: 'openvibe.blog' } }).target, 'openvibe.blog.post to site openvibe.blog');
+ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.blog.post', input: {} }).target, null, 'no place named, no target');
+ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.mail.send', input: { to: '[email protected]' } }).target, null);
+ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.tools.install_skill', input: { name: 'x' } }).target, null);
+ const pay = stepFor('run_tool', { tool: 'openvibe.wallet.pay', input: {} });
+ assert.strictEqual(pay.target, null);
+ assert.strictEqual(pay.category, 'money', 'money is the floor whatever the words say');
+ });
+
+ await check('some kinds can never be always allowed, whatever the target', async () => {
+ for (const kind of ['money', 'computer.input', 'send', 'install']) {
+ assert.strictEqual(kinds.allowable(kind), false, `${kind} always asks`);
+ assert.strictEqual(allows.matches([{ kind, target: 't' }], kind, 't'), false, `${kind}: no rule covers it`);
+ }
+ const d = await levels.decide({ level: 'ask', categories: [], act: 'act', allow: { kind: 'computer.input', target: 'type on dev_x', rules: [{ kind: 'computer.input', target: 'type on dev_x' }] } });
+ assert.strictEqual(d.ask, true, 'a stored rule for a never-allowed kind is ignored');
+ });
+
+ await check('the kinds cover the trust spine: computer, money, publishing, posting, sending, installing, routines', async () => {
+ for (const kind of ['computer.run', 'computer.write', 'computer.input', 'computer.browser', 'money', 'publish', 'post', 'send', 'install', 'routine']) {
+ assert.ok(kinds.isKind(kind), `${kind} is a kind`);
+ assert.ok(kinds.label(kind), `${kind} has plain words`);
+ }
+ // A generic OpenVibe.Tools tool takes the kind of the tool it runs; a plain lookup still does not ask.
+ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.blog.post', input: {} }).kind, 'post');
+ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.tools.install_skill', input: {} }).kind, 'install');
+ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.mail.send', input: {} }).kind, 'send');
+ assert.strictEqual(stepFor('run_tool', { tool: 'openvibe.wallet.pay', input: {} }).kind, 'money');
+ assert.strictEqual(stepFor('run_tool', { tool: 'dns.lookup', input: {} }), null, 'a lookup stays a lookup');
+ });
+
+ await check('the table accepts every kind the code can ask for', async () => {
+ // The migration widens the check on the kinds column; the two lists must not drift apart.
+ const dir = path.join(__dirname, '..', 'migrations');
+ const file = fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort()
+ .map((f) => ({ f, sql: fs.readFileSync(path.join(dir, f), 'utf8') }))
+ .filter((m) => m.sql.includes('actor_approvals_kind_check')).pop();
+ assert.ok(file, 'the migration that widens the kinds is there');
+ const listed = (file.sql.match(/CHECK \(kind IN \(([^)]*)\)\)/) || ['', ''])[1]
+ .split(',').map((k) => k.trim().replace(/^'|'$/g, '')).filter(Boolean);
+ assert.deepStrictEqual(listed, kinds.KINDS, `${file.f} and agents/kinds.js agree on the kinds`);
+ });
+
+ await check('the Action Center item carries the kind, the agent by name, and its own page', async () => {
+ const item = items.approvalItem({
+ id: 'apr_01JAB2C3D4E5F6G7H8J9K0MNPS', kind: 'publish', summary: 'Publish your post on openvibe.blog',
+ state: 'pending', created_at: '2026-11-07T10:00:00.000Z', agent_id: 'agt_01JAB2C3D4E5F6G7H8J9K0MNPS',
+ }, { agentName: 'Vibe', thread: 'Weekly digest' });
+ assert.strictEqual(item.service, 'actor');
+ assert.strictEqual(item.kind, 'approval');
+ assert.strictEqual(item.bucket, 'waiting');
+ assert.strictEqual(item.title, 'Publish your post on openvibe.blog');
+ assert.match(item.summary, /publishing for others to see/, 'the kind in plain words');
+ assert.match(item.summary, /Weekly digest/, 'the thread it came from');
+ assert.strictEqual(item.agent, 'Vibe', 'the agent by its name, never its id');
+ assert.strictEqual(item.href, 'https://openvibe.actor/me/approvals/apr_01JAB2C3D4E5F6G7H8J9K0MNPS');
+ });
+
+ // ── The engine: ask, allow always, and take it back ──
+ const asked = [];
+ const t = await boot({
+ loop: {
+ close: async () => {},
+ run: async (args) => {
+ // One step of each kind the task asks for: the same command, and another one.
+ const step = stepFor('computer_run', { machine: 'dev_x', argv: args.task.includes('other') ? ['git', 'log'] : ['git', 'status'] });
+ const ask = await args.requestApproval({ ...step });
+ asked.push({ task: args.task, pending: Boolean(ask && ask.pending) });
+ return { text: 'Done.', sources: [], evidence: [], model: 'fake' };
+ },
+ },
+ });
+ try {
+ const ada = t.network.addUser('ada');
+ const owner = `user:${ada.subject}`;
+ const agent = (await contacts.create(t.ctx.s, t.config, owner, { name: 'Fixer', level: 'ask' })).row;
+ const ask = (task) => t.get('/api/v1/tasks', { as: ada, json: { task, agent_id: agent.id }, headers: SAME });
+ const pending = async () => (await approvals.list(t.ctx.s, owner)).filter((a) => a.state === 'pending');
+
+ await check('a consequential step asks, and the request says what kind of act it is', async () => {
+ const res = await ask('run git status on dev_x');
+ assert.strictEqual(res.status, 201, res.text);
+ await t.waitFor(res.json().id);
+ const rows = await pending();
+ assert.strictEqual(rows.length, 1, 'one request waits');
+ const row = rows[0];
+ assert.strictEqual(row.kind, 'computer.run');
+ assert.strictEqual(row.target, 'git status on dev_x');
+ assert.match(row.summary, /Run "git status" on your computer/);
+ assert.ok(row.categories !== undefined, 'the floor categories are stored');
+ assert.strictEqual(row.agent_id, agent.id);
+ // The page reads in plain words, and offers both ways to say yes.
+ const page = await t.get(`/me/approvals/${row.id}`, { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /running a program on your computer/, 'the kind in plain words');
+ assert.match(page.text, /Always allow/, 'the person may allow it always');
+ assert.match(page.text, /Approve and start it|Approve for a retry/);
+ assert.match(page.text, /Fixer/, 'the agent by its name');
+ });
+
+ await check('approving once answers the request and leaves the agent asking next time', async () => {
+ const row = (await pending())[0];
+ const res = await t.get(`/me/approvals/${row.id}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(res.status, 303);
+ const after = await approvals.get(t.ctx.s, owner, row.id);
+ assert.strictEqual(after.state, 'approved');
+ assert.strictEqual(after.decision_scope, 'once');
+ assert.strictEqual(after.decided_by, owner, 'who decided');
+ assert.strictEqual((await allows.list(t.ctx.s, owner, agent.id)).length, 0, 'nothing is always allowed yet');
+ // The same step asks again.
+ const res2 = await ask('run git status on dev_x');
+ await t.waitFor(res2.json().id);
+ assert.strictEqual((await pending()).length, 1, 'it asks again');
+ });
+
+ await check('approve and always allow: the next identical ask does not ask, another target does', async () => {
+ const row = (await pending())[0];
+ const page = await t.get(`/me/approvals/${row.id}`, { as: ada });
+ assert.match(page.text, /Always allow this for Fixer/);
+ assert.match(page.text, /covers exactly this, and nothing else: running a program on your computer · git status on dev_x/);
+ const res = await t.get(`/me/approvals/${row.id}`, { as: ada, form: { decision: 'approve_always' }, headers: SAME });
+ assert.strictEqual(res.status, 303);
+ const after = await approvals.get(t.ctx.s, owner, row.id);
+ assert.strictEqual(after.state, 'approved');
+ assert.strictEqual(after.decision_scope, 'always');
+ assert.ok(after.allow_id, 'the standing permission is named');
+ const rules = await allows.list(t.ctx.s, owner, agent.id);
+ assert.strictEqual(rules.length, 1);
+ assert.deepStrictEqual([rules[0].kind, rules[0].target], ['computer.run', 'git status on dev_x']);
+
+ // The same command on the same machine goes on without asking.
+ const before = (await pending()).length;
+ const res2 = await ask('run git status on dev_x');
+ await t.waitFor(res2.json().id);
+ assert.strictEqual((await pending()).length, before, 'the identical step does not ask again');
+ const last = asked[asked.length - 1];
+ assert.strictEqual(last.pending, false, 'the gate let it through');
+
+ // Another command is another target: it asks.
+ const res3 = await ask('run other git log on dev_x');
+ await t.waitFor(res3.json().id);
+ const rows = await pending();
+ assert.strictEqual(rows.length, before + 1, 'a different target asks again');
+ assert.strictEqual(rows[rows.length - 1].target, 'git log on dev_x');
+ });
+
+ await check('the agent\'s page lists what it may always do, with Revoke, and the decision is in its activity', async () => {
+ const page = await t.get(`/me/agents/${agent.id}`, { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /running a program on your computer · git status on dev_x/, 'the standing permission is listed');
+ assert.match(page.text, /You approved it and allowed it from now on|You approved it and allowed it always/, 'the decision is in the agent\'s activity');
+ assert.match(page.text, /You approved it once/, 'the earlier decision is there too');
+ assert.match(page.text, /Revoke/);
+ });
+
+ await check('revoking restores the ask', async () => {
+ const rules = await allows.list(t.ctx.s, owner, agent.id);
+ const res = await t.get(`/me/agents/${agent.id}/allows/${rules[0].id}/revoke`, { as: ada, method: 'POST', headers: SAME });
+ assert.strictEqual(res.status, 303);
+ assert.strictEqual((await allows.list(t.ctx.s, owner, agent.id)).length, 0, 'the permission is gone');
+ const before = (await pending()).length;
+ const res2 = await ask('run git status on dev_x');
+ await t.waitFor(res2.json().id);
+ assert.strictEqual((await pending()).length, before + 1, 'it asks again');
+ });
+
+ await check('the floor is never covered by a standing permission', async () => {
+ const rows = await pending();
+ const money = rows.find((r) => r.kind === 'computer.run' && r.target === 'git status on dev_x');
+ assert.ok(money, 'the waiting request');
+ // A deletion is a floor step: the page offers no "always allow", and the answer is refused.
+ const del = await approvals.request(t.ctx.s, {
+ owner, agentId: agent.id, kind: 'computer.run', summary: 'Run "rm -rf build" on your computer',
+ payload: { argv: ['rm', '-rf', 'build'] }, target: 'rm -rf build on dev_x', categories: ['delete'],
+ });
+ const page = await t.get(`/me/approvals/${del.id}`, { as: ada });
+ assert.ok(!/Always allow/.test(page.text), 'a floor step has no always-allow button');
+ assert.match(page.text, /This always asks, so it can never be always allowed/);
+ assert.match(page.text, /This deletes something/, 'the caution says why');
+ const res = await t.get(`/me/approvals/${del.id}`, { as: ada, form: { decision: 'approve_always' }, headers: SAME });
+ assert.strictEqual(res.status, 422);
+ assert.match(res.text, /can never be always allowed/);
+ assert.strictEqual((await allows.list(t.ctx.s, owner, agent.id)).length, 0, 'nothing was allowed');
+ // And the floor asks even at full access, even with a rule for the same kind.
+ const decision = await levels.decide({
+ level: 'full', categories: ['money'], act: 'act', summary: 'Spend $10',
+ allow: { kind: 'money', target: 'example.com', rules: [{ kind: 'money', target: 'example.com' }] },
+ });
+ assert.strictEqual(decision.ask, true, 'the floor asks whatever the rules say');
+ });
+
+ await check('the decision is in the person\'s feed too', async () => {
+ const feed = await t.get('/console', { as: ada });
+ assert.strictEqual(feed.status, 200);
+ assert.match(feed.text, /You approved/, 'the person sees what they approved');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -155,7 +155,7 @@ function gate(level, asked) {
@@ -155,7 +155,7 @@ function gate(level, asked) {
assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));
assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));
const ask = await t.ctx.s.db.maybe('SELECT * FROM actor_approvals WHERE task_id = $1', [row.id]);
const ask = await t.ctx.s.db.maybe('SELECT * FROM actor_approvals WHERE task_id = $1', [row.id]);
assert.ok(ask, 'the approval was recorded');
assert.ok(ask, 'the approval was recorded');
- assert.deepStrictEqual([ask.kind, ask.state], ['actor.action', 'pending']);
+ assert.deepStrictEqual([ask.kind, ask.state], ['computer.run', 'pending']);
assert.strictEqual(JSON.parse(typeof ask.payload === 'string' ? ask.payload : JSON.stringify(ask.payload)).argv[0], 'rm');
assert.strictEqual(JSON.parse(typeof ask.payload === 'string' ? ask.payload : JSON.stringify(ask.payload)).argv[0], 'rm');
});
});