diff --git a/README.md b/README.md index bd8789d..38b0f0f 100644 --- a/README.md +++ b/README.md @@ -199,15 +199,16 @@ one list You need the connection key (rightmost column) when executing actions. -**Access reporting.** Every connection carries an `access` field describing what the current [access control](#one-config) config permits — so an agent knows its reach up front instead of discovering it as a failure mid-workflow. Three policies: +**Access reporting.** Every connection carries an `access` field describing what your API key may run there — so an agent knows its reach up front instead of discovering it as a failure mid-workflow. It is read live on every call from the key's rules on One (the per-app access set on the consent page or the dashboard), because those can change at any time, and then narrowed by any local [access control](#one-config) setting. `one list` reads them from `GET /v1/access/self`, which One resolves from the calling key itself, whether it is a personal, organization or project key. Four policies: | Policy | Means | Shown when | |--------|-------|-----------| -| `{"policy": "full"}` | Every action on the connection | Default (admin, no action allowlist) | -| `{"policy": "methods", "methods": ["GET"]}` | Only actions with those HTTP methods | Permission level is `read` or `write` | -| `{"policy": "actions", "actions": [{"actionId": "...", "title": "...", "method": "..."}]}` | Only these specific actions | An action allowlist is configured | +| `{"policy": "full"}` | Every action on the connection | The key's rules and local config allow everything | +| `{"policy": "methods", "methods": ["GET"]}` | Only actions of those methods | A rule (or the local permission level) limits the methods | +| `{"policy": "actions", "actions": [{"actionId": "...", "title": "...", "method": "..."}]}` | Only these specific actions | A rule (or the local allowlist) names actions; `[]` means nothing may run | +| `{"policy": "unknown"}` | The key's rules could not be read | `accessError` says why; nothing is assumed | -An action allowlist wins over the permission level (and is then method-filtered by it). When the policy is `actions`, those actions are exactly what may run — `actions load ` reads their docs, no find needed. +The key's rules on One match methods the way One enforces them: by the action's CRUD verb, so a read sent as POST counts as GET and an update counts as PUT. A local permission level checks the HTTP method. A connection the key's rules don't name reaches nothing. Local settings only narrow what the key allows, never widen it. When the policy is `actions`, those actions are exactly what may run — `actions load ` reads their docs, no find needed. ```jsonc // one --agent list @@ -226,7 +227,7 @@ An action allowlist wins over the permission level (and is then method-filtered } ``` -`knowledgeOnly: true` appears when knowledge-only mode is on (execution disabled), and `unresolvedActionIds` lists any allowlisted action ids that could not be looked up. In human output, an `Access` column and a summary note appear only when access is actually scoped. +`knowledgeOnly: true` appears when knowledge-only mode is on (execution disabled), `unresolvedActionIds` lists any allowed action ids that could not be looked up, and `accessError` explains an `unknown` access. In human output, an `Access` column and a summary note appear only when access is actually scoped. This mirrors the `access` field on the One MCP server's `list_one_integrations` tool, so both surfaces report access the same way. @@ -646,7 +647,7 @@ one config Settings propagate automatically to all installed agent configs. -Run [`one list`](#one-list) to see the effect: each connection reports an `access` field describing exactly what these settings let you run on it. +These settings are a local restriction on top of the API key's own rules on One, which are set on the dashboard. Run [`one list`](#one-list) to see the effect: each connection reports an `access` field combining both. #### `one config skills status` / `one config skills sync` diff --git a/package-lock.json b/package-lock.json index 40a9a96..f80b685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@withone/cli", - "version": "2.3.2", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@withone/cli", - "version": "2.3.2", + "version": "2.4.0", "dependencies": { "@clack/prompts": "^0.9.1", "commander": "^13.1.0", diff --git a/package.json b/package.json index d050dba..811a162 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@withone/cli", - "version": "2.3.2", + "version": "2.4.0", "description": "CLI for managing One", "type": "module", "files": [ diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 3a7fb68..8418091 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -47,19 +47,21 @@ Always follow this sequence when the user wants to do something on a connected p one --agent connection list ``` -Returns connected platforms with their connection keys (needed for execution), platform names in kebab-case (needed for finding actions), and an `access` field per connection telling you what you may run there. +Returns connected platforms with their connection keys (needed for execution), platform names in kebab-case (needed for finding actions), and an `access` field per connection telling you what you may run there. `access` is read live from this API key's rules on One (set on the dashboard, so they can change between calls), narrowed by any local `one config` restriction. **Read `access` before you plan a workflow** — it saves you from discovering a restriction as a 403 halfway through: | `access` | What it means | |----------|---------------| | `{"policy": "full"}` | Every action on this connection is available | -| `{"policy": "methods", "methods": ["GET"]}` | Only actions with these HTTP methods will execute — don't propose writes | +| `{"policy": "methods", "methods": ["GET"]}` | Only actions of these methods will execute — don't propose writes. The key's rules on One judge an action by its CRUD verb (a read sent as POST counts as GET; an update counts as PUT); a local `one config` level checks the HTTP method | | `{"policy": "actions", "actions": [...]}` | Only these exact actions may run. Each has `actionId`, `title`, `method` — **use them directly**: `actions load ` reads their docs, no find needed | +| `{"policy": "unknown"}` | The key's rules couldn't be read; `accessError` says why. Don't assume full access — tell the user, and expect a 403 on anything the key may not run | -Two more fields appear only when relevant: +Three more fields appear only when relevant: - `"knowledgeOnly": true` — `actions execute` is disabled. `actions find` returns each action's whole document with how to call it from code; write integration code instead of executing. -- `"unresolvedActionIds": [...]` — allowlisted ids that couldn't be looked up; treat them as unavailable and tell the user. +- `"unresolvedActionIds": [...]` — allowed action ids that couldn't be looked up; treat them as unavailable and tell the user. +- `"accessError": "..."` — why access is `unknown`. An empty `actions` array means the allowlist grants nothing on that connection — say so rather than looking for alternatives. diff --git a/src/cli.ts b/src/cli.ts index 4295145..66ec62c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -431,7 +431,7 @@ connection connection .command('list') .alias('ls') - .description('List your connections and what your access config lets you run on each') + .description("List your connections and what your API key's rules on One let you run on each") .option('-s, --search ', 'Filter connections by platform name') .option('-l, --limit ', 'Max connections to return (agent mode default: 20)') .action(async (options) => { diff --git a/src/commands/connection.ts b/src/commands/connection.ts index 109c1fd..528d5e1 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -1,13 +1,20 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; import { getApiKey, getApiBase, getAccessControlFromAllSources, ensureWhoAmI, getEnvFromApiKey } from '../lib/config.js'; -import { OneApi, TimeoutError, isMethodAllowed, PERMISSION_METHODS } from '../lib/api.js'; -import { resolveAllowedActions, computeConnectionAccess, formatAccess } from '../lib/access.js'; +import { ApiError, OneApi, TimeoutError, PERMISSION_METHODS } from '../lib/api.js'; +import { + resolveAllowedActions, + computeConnectionAccess, + formatAccess, + keyAccessActionIds, + parseKeyAccess, + serverEnvelope, +} from '../lib/access.js'; import { openConnectionPage, getConnectionUrl, type ConnectionUrlParams } from '../lib/browser.js'; import { findPlatform, findSimilarPlatforms } from '../lib/platforms.js'; import { printTable } from '../lib/table.js'; import * as output from '../lib/output.js'; -import type { Connection, ConnectionAccess, PermissionLevel } from '../lib/types.js'; +import type { Connection, ConnectionAccess, KeyAccess, PermissionLevel } from '../lib/types.js'; export async function connectionAddCommand(platformArg?: string, options?: { tag?: string }): Promise { if (output.isAgentMode()) { @@ -174,29 +181,49 @@ export async function connectionListCommand(options?: { search?: string; limit?: spinner.start('Loading connections...'); try { - const allConnections = await api.listConnections(); - - // Filter by access control settings const ac = getAccessControlFromAllSources(); const permissions: PermissionLevel = ac.permissions || 'admin'; const allowedKeys = ac.connectionKeys || ['*']; const actionIds = ac.actionIds || ['*']; const knowledgeOnly = ac.knowledgeAgent || false; + + // The key's rules are read live on every listing: they can be edited on + // the dashboard at any time, so nothing about them is cached locally. + // Every action id they or the local allowlist name is resolved as soon as + // they arrive, alongside the connection listing, so the listing answers + // "what can I run here" without a follow-up search. + const [allConnections, { keyAccess, idsToResolve, resolvedActions }] = await Promise.all([ + api.listConnections(), + readKeyAccess(api).then(async keyAccess => { + if (!keyAccess.ok) return { keyAccess, idsToResolve: [], resolvedActions: [] }; + const ids = [ + ...new Set([ + ...keyAccessActionIds(keyAccess.access), + ...(actionIds.includes('*') ? [] : actionIds), + ]), + ]; + return { keyAccess, idsToResolve: ids, resolvedActions: await resolveAllowedActions(api, ids) }; + }), + ]); + const accessFiltered = allowedKeys.includes('*') ? allConnections : allConnections.filter(conn => allowedKeys.includes(conn.key)); - // Resolve what the access config actually permits, so the listing answers - // "what can I run here" without a follow-up search. No network cost unless - // an action allowlist is configured. - const resolvedActions = await resolveAllowedActions(api, actionIds); - const unresolvedActionIds = actionIds.includes('*') - ? [] - : actionIds.filter(id => !resolvedActions.some(a => a.actionId === id)); - const grantedActions = resolvedActions.filter(a => isMethodAllowed(a.method, permissions)); - const accessFor = (platform: string): ConnectionAccess => - computeConnectionAccess(platform, permissions, actionIds, grantedActions); + const serverAccess = keyAccess.ok ? keyAccess.access : null; + const unresolvedActionIds = idsToResolve.filter(id => !resolvedActions.some(a => a.actionId === id)); + const accessFor = (conn: Connection): ConnectionAccess => + keyAccess.ok + ? computeConnectionAccess({ + platform: conn.platform, + envelope: serverEnvelope(serverAccess, conn), + permissions, + localActionIds: actionIds, + resolved: resolvedActions, + }) + : { policy: 'unknown' }; const hintText = accessHint(permissions, actionIds, knowledgeOnly); + const accessError = keyAccess.ok ? null : keyAccess.reason; // Filter by search query if provided const searchQuery = options?.search?.toLowerCase(); @@ -220,9 +247,10 @@ export async function connectionListCommand(options?: { search?: string; limit?: key: conn.key, ...(conn.name && { name: conn.name }), ...(conn.tags?.length && { tags: conn.tags }), - access: accessFor(conn.platform), + access: accessFor(conn), })), ...(knowledgeOnly && { knowledgeOnly: true }), + ...(accessError && { accessError }), ...(unresolvedActionIds.length > 0 && { unresolvedActionIds }), ...(hintText && { accessHint: hintText }), ...(limited.length < filtered.length && { @@ -261,7 +289,7 @@ export async function connectionListCommand(options?: { search?: string; limit?: state: conn.state, key: conn.key, tags: conn.tags?.length ? conn.tags.join(', ') : '', - access: formatAccess(accessFor(conn.platform)), + access: formatAccess(accessFor(conn)), })); const hasTags = rows.some(r => r.tags); @@ -283,14 +311,22 @@ export async function connectionListCommand(options?: { search?: string; limit?: console.log(); + if (accessError) { + p.note(wrapText(accessError), 'Access unknown'); + } + if (hintText) { - const lines = [hintText]; - if (unresolvedActionIds.length > 0) { - lines.push( - `Could not resolve ${unresolvedActionIds.length} allowlisted action id(s): ${unresolvedActionIds.join(', ')}` - ); - } - p.note(`${wrapText(lines.join('\n'))}\n\nChange it with: ${pc.cyan('one config')}`, 'Access'); + p.note(`${wrapText(hintText)}\n\nChange it with: ${pc.cyan('one config')}`, 'Access'); + } + + if (unresolvedActionIds.length > 0) { + p.note( + wrapText( + `Could not look up ${unresolvedActionIds.length} allowed action id(s), so they are left out: ` + + unresolvedActionIds.join(', ') + ), + 'Access' + ); } if (displayed.length < filtered.length) { @@ -388,6 +424,31 @@ export async function connectionDeleteCommand( } } +/** + * The calling key's access rules on One, read from `GET /v1/access/self`. + * A failure is reported, never read as "unrestricted". + */ +async function readKeyAccess( + api: OneApi +): Promise<{ ok: true; access: KeyAccess | null } | { ok: false; reason: string }> { + try { + return { ok: true, access: parseKeyAccess(await api.getKeyAccess()) }; + } catch (error) { + const detail = + error instanceof ApiError && error.status === 404 + ? 'this One API does not serve `/v1/access/self` yet' + : error instanceof Error + ? error.message + : String(error); + return { + ok: false, + reason: + `Could not read this key's access rules from One (${detail}). ` + + 'Access is reported as unknown; actions the key may not run fail with 403.', + }; + } +} + /** * One-line explanation of a restricted access config, told from the caller's * point of view ("you can only..."). Returns null when nothing is restricted — diff --git a/src/lib/access.test.ts b/src/lib/access.test.ts index c12fd95..5903319 100644 --- a/src/lib/access.test.ts +++ b/src/lib/access.test.ts @@ -3,61 +3,264 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { computeConnectionAccess, formatAccess, resolveAllowedActions } from './access.js'; -import type { ActionDetails, ResolvedAllowedAction } from './types.js'; -import { setHomeTo, snapshotHomeEnv, restoreHomeEnv, type HomeEnvSnapshot } from '../test-support/home.js'; +import { + UNRESTRICTED, + computeConnectionAccess, + formatAccess, + keyAccessActionIds, + parseKeyAccess, + resolveAllowedActions, + serverEnvelope, +} from './access.js'; +import type { ActionDetails, KeyAccess, ResolvedAllowedAction } from './types.js'; +import { + assertHomeIsSandboxed, + setHomeTo, + snapshotHomeEnv, + restoreHomeEnv, + type HomeEnvSnapshot, +} from '../test-support/home.js'; const granted: ResolvedAllowedAction[] = [ - { actionId: 'a1', title: 'Send Email', method: 'POST', platform: 'gmail' }, - { actionId: 'a2', title: 'List Messages', method: 'GET', platform: 'gmail' }, - { actionId: 'a3', title: 'Post Message', method: 'POST', platform: 'slack' }, + { actionId: 'a1', title: 'Send Email', method: 'POST', envelopeMethod: 'POST', platform: 'gmail' }, + { actionId: 'a2', title: 'List Messages', method: 'GET', envelopeMethod: 'GET', platform: 'gmail' }, + { actionId: 'a3', title: 'Post Message', method: 'POST', envelopeMethod: 'POST', platform: 'slack' }, + { actionId: 'a4', title: 'Search Messages', method: 'POST', envelopeMethod: 'GET', platform: 'gmail' }, ]; -describe('computeConnectionAccess', () => { +const local = (permissions: 'read' | 'write' | 'admin', localActionIds: string[] = ['*']) => ({ + permissions, + localActionIds, + resolved: granted, +}); + +describe('computeConnectionAccess with no rules on One (local config only)', () => { it('reports full access for admin with no action allowlist', () => { assert.deepEqual( - computeConnectionAccess('gmail', 'admin', ['*'], []), + computeConnectionAccess({ platform: 'gmail', envelope: UNRESTRICTED, ...local('admin') }), { policy: 'full' } ); }); it('reports the method set for a non-admin permission level', () => { assert.deepEqual( - computeConnectionAccess('gmail', 'read', ['*'], []), + computeConnectionAccess({ platform: 'gmail', envelope: UNRESTRICTED, ...local('read') }), { policy: 'methods', methods: ['GET'] } ); assert.deepEqual( - computeConnectionAccess('gmail', 'write', ['*'], []), + computeConnectionAccess({ platform: 'gmail', envelope: UNRESTRICTED, ...local('write') }), { policy: 'methods', methods: ['GET', 'POST', 'PUT', 'PATCH'] } ); }); it('lets an action allowlist win over the permission level, scoped to the platform', () => { - const access = computeConnectionAccess('gmail', 'admin', ['a1', 'a2', 'a3'], granted); - assert.equal(access.policy, 'actions'); - assert.deepEqual(access.policy === 'actions' ? access.actions : null, [ - { actionId: 'a1', title: 'Send Email', method: 'POST' }, - { actionId: 'a2', title: 'List Messages', method: 'GET' }, - ]); + assert.deepEqual( + computeConnectionAccess({ platform: 'gmail', envelope: UNRESTRICTED, ...local('admin', ['a1', 'a2', 'a3']) }), + { + policy: 'actions', + actions: [ + { actionId: 'a1', title: 'Send Email', method: 'POST' }, + { actionId: 'a2', title: 'List Messages', method: 'GET' }, + ], + } + ); + }); + + it('drops allowlisted actions the permission level forbids', () => { + assert.deepEqual( + computeConnectionAccess({ platform: 'gmail', envelope: UNRESTRICTED, ...local('read', ['a1', 'a2']) }), + { policy: 'actions', actions: [{ actionId: 'a2', title: 'List Messages', method: 'GET' }] } + ); }); it('reports an empty action list for a platform the allowlist does not cover', () => { assert.deepEqual( - computeConnectionAccess('shopify', 'admin', ['a1'], granted), + computeConnectionAccess({ platform: 'shopify', envelope: UNRESTRICTED, ...local('admin', ['a1']) }), { policy: 'actions', actions: [] } ); }); }); +describe('parseKeyAccess', () => { + it('reads a null body as no restrictions', () => { + assert.equal(parseKeyAccess(null), null); + }); + + it('reads the rules the access route returns', () => { + assert.deepEqual(parseKeyAccess({ methods: null, rules: [{ type: 'connection', connectionKey: 'k', methods: ['GET'] }] }), { + methods: null, + rules: [{ type: 'connection', connectionKey: 'k', methods: ['GET'] }], + }); + }); + + it('refuses any other body rather than reading it as unrestricted', () => { + assert.throws(() => parseKeyAccess({})); + assert.throws(() => parseKeyAccess({ data: { methods: ['GET'], rules: null } })); + assert.throws(() => parseKeyAccess('null')); + }); + + it('refuses rules of the wrong shape instead of failing later', () => { + assert.throws(() => parseKeyAccess({ methods: null, rules: {} })); + assert.throws(() => parseKeyAccess({ methods: 'GET', rules: null })); + assert.throws(() => parseKeyAccess({ methods: null, rules: [{ type: 'connection' }] })); + assert.throws(() => parseKeyAccess({ methods: null, rules: [{ type: 'platform', systemId: 'x' }] })); + assert.throws(() => + parseKeyAccess({ methods: null, rules: [{ type: 'connection', connectionKey: 'k', actionIds: 'a1' }] }) + ); + }); +}); + +describe('serverEnvelope', () => { + const gmail = { key: 'live::gmail::default::g1' }; + const stripe = { key: 'live::stripe::default::s1' }; + + it('leaves a key with no restrictions unbounded', () => { + assert.deepEqual(serverEnvelope(null, gmail), UNRESTRICTED); + }); + + it('bounds every connection by the global methods when there are no rules', () => { + assert.deepEqual(serverEnvelope({ methods: ['GET'], rules: null }, gmail), { methods: ['GET'], actionIds: null }); + }); + + it('reaches nothing on a connection no rule names, including under an empty rule list', () => { + const access: KeyAccess = { + methods: null, + rules: [{ type: 'connection', connectionKey: stripe.key, methods: ['GET'] }], + }; + assert.equal(serverEnvelope(access, gmail), null); + assert.equal(serverEnvelope({ methods: null, rules: [] }, gmail), null); + }); + + it("uses a rule's own methods over the global ones, and inherits them when it lists none", () => { + const access: KeyAccess = { + methods: ['GET'], + rules: [ + { type: 'connection', connectionKey: gmail.key, methods: ['GET', 'POST'] }, + { type: 'connection', connectionKey: stripe.key, actionIds: ['x1'] }, + ], + }; + assert.deepEqual(serverEnvelope(access, gmail), { methods: ['GET', 'POST'], actionIds: null }); + assert.deepEqual(serverEnvelope(access, stripe), { methods: ['GET'], actionIds: ['x1'] }); + }); + + it("keeps a rule's empty method list empty rather than inheriting", () => { + const access: KeyAccess = { + methods: ['GET'], + rules: [{ type: 'connection', connectionKey: gmail.key, methods: [] }], + }; + assert.deepEqual(serverEnvelope(access, gmail), { methods: [], actionIds: null }); + }); + + it('treats a method list covering every CRUD verb as unbounded (the dashboard\'s "Full access")', () => { + const access: KeyAccess = { + methods: null, + rules: [ + { + type: 'connection', + connectionKey: gmail.key, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE'], + }, + ], + }; + assert.deepEqual(serverEnvelope(access, gmail), UNRESTRICTED); + }); + + it('collects every action id the rules name', () => { + const access: KeyAccess = { + methods: null, + rules: [ + { type: 'connection', connectionKey: gmail.key, actionIds: ['a1'] }, + { type: 'connection', connectionKey: stripe.key, methods: ['GET'] }, + { type: 'connection', connectionKey: 'live::slack::default::k1', actionIds: ['a2', 'a3'] }, + ], + }; + assert.deepEqual(keyAccessActionIds(access), ['a1', 'a2', 'a3']); + assert.deepEqual(keyAccessActionIds(null), []); + }); +}); + +describe('computeConnectionAccess with rules on One', () => { + it('reports the dashboard grant: read/write calendar, read-only gmail, one stripe action', () => { + const access: KeyAccess = { + methods: null, + rules: [ + { type: 'connection', connectionKey: 'cal', methods: ['GET', 'POST', 'PUT', 'PATCH'] }, + { type: 'connection', connectionKey: 'gm', methods: ['GET'] }, + { type: 'connection', connectionKey: 'st', actionIds: ['s1'] }, + ], + }; + const resolved: ResolvedAllowedAction[] = [ + { actionId: 's1', title: 'List Invoices', method: 'GET', envelopeMethod: 'GET', platform: 'stripe' }, + ]; + const accessOn = (platform: string, key: string) => + computeConnectionAccess({ + platform, + envelope: serverEnvelope(access, { key }), + permissions: 'admin', + localActionIds: ['*'], + resolved, + }); + + assert.deepEqual(accessOn('google-calendar', 'cal'), { policy: 'methods', methods: ['GET', 'POST', 'PUT', 'PATCH'] }); + assert.deepEqual(accessOn('gmail', 'gm'), { policy: 'methods', methods: ['GET'] }); + assert.deepEqual(accessOn('stripe', 'st'), { + policy: 'actions', + actions: [{ actionId: 's1', title: 'List Invoices', method: 'GET' }], + }); + }); + + it('reports no access on a connection no rule names', () => { + assert.deepEqual( + computeConnectionAccess({ platform: 'gmail', envelope: null, ...local('admin') }), + { policy: 'actions', actions: [] } + ); + }); + + it("judges a rule's actions by their CRUD verb, not the HTTP method", () => { + assert.deepEqual( + computeConnectionAccess({ + platform: 'gmail', + envelope: { methods: ['GET'], actionIds: ['a1', 'a2', 'a4'] }, + ...local('admin'), + }), + { + policy: 'actions', + actions: [ + { actionId: 'a2', title: 'List Messages', method: 'GET' }, + { actionId: 'a4', title: 'Search Messages', method: 'POST' }, + ], + } + ); + }); + + it('narrows the rules by the local permission level and allowlist, never widening them', () => { + assert.deepEqual( + computeConnectionAccess({ platform: 'gmail', envelope: { methods: ['GET', 'POST'], actionIds: null }, ...local('read') }), + { policy: 'methods', methods: ['GET'] } + ); + assert.deepEqual( + computeConnectionAccess({ platform: 'gmail', envelope: { methods: null, actionIds: ['a1', 'a2'] }, ...local('admin', ['a2', 'a3']) }), + { policy: 'actions', actions: [{ actionId: 'a2', title: 'List Messages', method: 'GET' }] } + ); + }); + + it('reports an empty method list as reaching nothing', () => { + const access = computeConnectionAccess({ platform: 'gmail', envelope: { methods: [], actionIds: null }, ...local('admin') }); + assert.deepEqual(access, { policy: 'methods', methods: [] }); + assert.equal(formatAccess(access), 'none'); + }); +}); + describe('formatAccess', () => { it('renders each policy for the table', () => { assert.equal(formatAccess({ policy: 'full' }), 'full'); + assert.equal(formatAccess({ policy: 'unknown' }), 'unknown'); assert.equal(formatAccess({ policy: 'methods', methods: ['GET', 'POST'] }), 'GET, POST'); assert.equal(formatAccess({ policy: 'actions', actions: [] }), 'none'); }); it('truncates long action lists', () => { - const actions = granted.map(({ actionId, title, method }) => ({ actionId, title, method })); + const actions = granted.slice(0, 3).map(({ actionId, title, method }) => ({ actionId, title, method })); assert.equal( formatAccess({ policy: 'actions', actions }), 'Send Email (POST), List Messages (GET) +1 more' @@ -75,6 +278,7 @@ describe('resolveAllowedActions', () => { originalHome = snapshotHomeEnv(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'one-cli-access-test-')); setHomeTo(tmpDir); + assertHomeIsSandboxed(); }); afterEach(() => { @@ -103,15 +307,17 @@ describe('resolveAllowedActions', () => { assert.deepEqual(calls, []); }); - it('resolves ids to title, method, and owning platform', async () => { + it('resolves ids to title, method, CRUD verb, and owning platform', async () => { const { api } = stubApi({ - a1: { _id: 'a1', title: 'Send Email', method: 'POST', path: '/send', connectionPlatform: 'gmail' }, + a1: { _id: 'a1', title: 'Send Email', method: 'POST', envelopeMethod: 'POST', path: '/send', connectionPlatform: 'gmail' }, + a4: { _id: 'a4', title: 'Search Messages', method: 'POST', envelopeMethod: 'GET', path: '/search', connectionPlatform: 'gmail' }, a3: { _id: 'a3', title: 'Post Message', method: 'POST', path: '/post', connectionPlatform: 'slack' }, }); - assert.deepEqual(await resolveAllowedActions(api, ['a1', 'a3']), [ - { actionId: 'a1', title: 'Send Email', method: 'POST', platform: 'gmail' }, - { actionId: 'a3', title: 'Post Message', method: 'POST', platform: 'slack' }, + assert.deepEqual(await resolveAllowedActions(api, ['a1', 'a4', 'a3']), [ + { actionId: 'a1', title: 'Send Email', method: 'POST', envelopeMethod: 'POST', platform: 'gmail' }, + { actionId: 'a4', title: 'Search Messages', method: 'POST', envelopeMethod: 'GET', platform: 'gmail' }, + { actionId: 'a3', title: 'Post Message', method: 'POST', envelopeMethod: 'POST', platform: 'slack' }, ]); }); @@ -126,13 +332,49 @@ describe('resolveAllowedActions', () => { assert.deepEqual(resolved.map(a => a.actionId), ['a1']); }); + it('resolves each id once even when named twice', async () => { + const { api, calls } = stubApi({ + a1: { _id: 'a1', title: 'Send Email', method: 'POST', envelopeMethod: 'POST', path: '/send', connectionPlatform: 'gmail' }, + }); + + assert.equal((await resolveAllowedActions(api, ['a1', 'a1'])).length, 1); + assert.deepEqual(calls, ['a1']); + }); + it('serves repeat lookups from the knowledge cache', async () => { const { api, calls } = stubApi({ - a1: { _id: 'a1', title: 'Send Email', method: 'POST', path: '/send', connectionPlatform: 'gmail' }, + a1: { _id: 'a1', title: 'Send Email', method: 'POST', envelopeMethod: 'POST', path: '/send', connectionPlatform: 'gmail' }, }); await resolveAllowedActions(api, ['a1']); await resolveAllowedActions(api, ['a1']); assert.deepEqual(calls, ['a1'], 'second resolution should hit the cache, not the API'); }); + + it('refetches a cached entry that predates envelopeMethod instead of judging it by HTTP method', async () => { + const before = stubApi({ + a4: { _id: 'a4', title: 'Search Messages', method: 'POST', path: '/search', connectionPlatform: 'gmail' }, + }); + await resolveAllowedActions(before.api, ['a4']); + + const after = stubApi({ + a4: { _id: 'a4', title: 'Search Messages', method: 'POST', envelopeMethod: 'GET', path: '/search', connectionPlatform: 'gmail' }, + }); + const [resolved] = await resolveAllowedActions(after.api, ['a4']); + + assert.equal(resolved.envelopeMethod, 'GET'); + assert.deepEqual(after.calls, ['a4']); + }); + + it('keeps a cached entry that predates envelopeMethod when the refetch fails', async () => { + const before = stubApi({ + a4: { _id: 'a4', title: 'Search Messages', method: 'POST', path: '/search', connectionPlatform: 'gmail' }, + }); + await resolveAllowedActions(before.api, ['a4']); + + const offline = stubApi({ a4: new Error('network down') }); + const resolved = await resolveAllowedActions(offline.api, ['a4']); + + assert.deepEqual(resolved.map(a => a.actionId), ['a4']); + }); }); diff --git a/src/lib/access.ts b/src/lib/access.ts index c92fa56..217e330 100644 --- a/src/lib/access.ts +++ b/src/lib/access.ts @@ -1,29 +1,65 @@ /** - * Access resolution — turning the configured access control (permission level, - * connection scope, action allowlist) into a concrete answer to "what can I run - * on this connection?". + * Access resolution — turning the key's rules on One, narrowed by the local + * access config (permission level, action allowlist), into a concrete answer + * to "what can I run on this connection?". * * `one list` reports this per connection so an agent knows its reach up front, * instead of discovering it as a 403 halfway through a workflow. The shapes * mirror the One MCP server's `list_one_integrations` `access` field, so an * agent that has learned one surface already understands the other. * - * Precedence (same as the MCP server and One core): - * action allowlist > permission level > full access + * The key's rules are read live from `GET /v1/access/self` on every listing, + * because they can be edited on the dashboard at any time. That route resolves + * the calling key in its own tenancy, so the CLI never needs the key's id or + * scope. The rules are matched the way One enforces them + * (`EventAccessConnection::is_allowed`): + * - no rules → only the key's global `methods` bound it + * - rules → a connection no rule names is unreachable; a rule's own + * `methods` override the global ones, and its + * `actionIds` narrow it to those actions + * - methods → judged against an action's CRUD verb (`envelopeMethod`) + * The local config is the CLI's own extra restriction on top, never a widening. */ import type { OneApi } from './api.js'; -import { PERMISSION_METHODS } from './api.js'; +import { PERMISSION_METHODS, isMethodAllowed } from './api.js'; import { resolveActionDetails } from './action-details.js'; import type { + Connection, ConnectionAccess, + KeyAccess, + KeyAccessRule, PermissionLevel, ResolvedAllowedAction, } from './types.js'; /** - * Resolves an allowlist of action ids to their metadata (title, method, and - * owning platform), so each connection can report the exact actions it may run. + * What a key's rules on One allow on one connection; a `null` field is + * unbounded. Where an envelope is optional, `null` means the key has rules + * and none names the connection, so it reaches nothing there. + */ +export interface AccessEnvelope { + methods: string[] | null; + actionIds: string[] | null; +} + +/** The envelope of a key with no restrictions on One. */ +export const UNRESTRICTED: AccessEnvelope = { methods: null, actionIds: null }; + +/** + * Every method One judges an action by (`CrudAction::semantic_method`). A + * method list covering all of them bounds nothing, which is how the dashboard + * stores "Full access" on a rule (every HTTP method, listed). + */ +const CRUD_METHODS = ['GET', 'POST', 'PUT', 'DELETE']; + +function boundingMethods(methods: string[] | null): string[] | null { + return methods !== null && CRUD_METHODS.every(m => methods.includes(m)) ? null : methods; +} + +/** + * Resolves action ids to their metadata (title, method, CRUD verb, and owning + * platform), so each connection can report the exact actions it may run. * * `['*']` (unrestricted) resolves to an empty list — there is no allowlist to * enumerate, and no network call is made. Ids that fail to resolve, or that @@ -40,16 +76,25 @@ export async function resolveAllowedActions( if (actionIds.includes('*')) return []; const resolved = await Promise.all( - actionIds.map(async (actionId): Promise => { + [...new Set(actionIds)].map(async (actionId): Promise => { try { // Silence the stale-cache warning: a listing shouldn't print a network // notice per allowlisted action. - const { details } = await resolveActionDetails(api, actionId, { warn: () => {} }); + let { details } = await resolveActionDetails(api, actionId, { warn: () => {} }); + // A cache entry older than `envelopeMethod` would judge the action by + // its HTTP method; refetch rather than guess its CRUD verb, keeping the + // cached entry when the refetch fails (offline). + if (details.envelopeMethod === undefined) { + details = await resolveActionDetails(api, actionId, { useCache: false, warn: () => {} }) + .then(fresh => fresh.details) + .catch(() => details); + } if (!details.connectionPlatform) return null; return { actionId, title: details.title, method: details.method, + envelopeMethod: (details.envelopeMethod ?? details.method).toUpperCase(), platform: details.connectionPlatform, }; } catch { @@ -62,30 +107,102 @@ export async function resolveAllowedActions( } /** - * What the current access config lets you run on a connection of `platform`. + * Reads the `/access` route's body. `null` is the only "no restrictions" + * answer; any other body that does not carry both `methods` and `rules` is an + * error, so a malformed response can never be read as an unrestricted key. + */ +export function parseKeyAccess(body: unknown): KeyAccess | null { + if (body === null) return null; + if (typeof body !== 'object' || !('methods' in body) || !('rules' in body)) { + throw new Error('unexpected response from the access route'); + } + + const { methods, rules } = body as { methods: unknown; rules: unknown }; + if (!isOptionalStrings(methods) || !(rules === null || (Array.isArray(rules) && rules.every(isKeyAccessRule)))) { + throw new Error('unexpected access rules from the access route'); + } + return { methods: methods ?? null, rules: rules ?? null }; +} + +function isOptionalStrings(value: unknown): value is string[] | null | undefined { + return value == null || (Array.isArray(value) && value.every(v => typeof v === 'string')); +} + +function isKeyAccessRule(rule: unknown): rule is KeyAccessRule { + if (typeof rule !== 'object' || rule === null) return false; + const r = rule as Record; + return ( + r.type === 'connection' && + typeof r.connectionKey === 'string' && + isOptionalStrings(r.methods) && + isOptionalStrings(r.actionIds) + ); +} + +/** Every action id the key's rules name, so they can be resolved in one pass. */ +export function keyAccessActionIds(access: KeyAccess | null): string[] { + return (access?.rules ?? []).flatMap(rule => rule.actionIds ?? []); +} + +/** + * What the key's rules on One allow on `connection`, matched the way One's + * `EventAccessConnection::is_allowed` matches them. `access: null` means the + * key carries no restrictions. + */ +export function serverEnvelope( + access: KeyAccess | null, + connection: Pick +): AccessEnvelope | null { + if (!access) return UNRESTRICTED; + if (access.rules === null) return { methods: boundingMethods(access.methods), actionIds: null }; + + const rule = access.rules.find(r => r.connectionKey === connection.key); + if (!rule) return null; + + return { methods: boundingMethods(rule.methods ?? access.methods), actionIds: rule.actionIds ?? null }; +} + +/** + * What the key may run on a connection of `platform`: the key's rules on One + * (`envelope`), narrowed by the local permission level and action allowlist. * - * `grantedActions` is the allowlist already resolved by `resolveAllowedActions` - * and filtered by the permission level — pass `[]` when the allowlist is `['*']`. + * `resolved` holds every action id either side names, already resolved by + * `resolveAllowedActions`. With an `UNRESTRICTED` envelope this is exactly the + * local-config answer. */ -export function computeConnectionAccess( - platform: string, - permissions: PermissionLevel, - allowedActionIds: string[], - grantedActions: ResolvedAllowedAction[] -): ConnectionAccess { - if (!allowedActionIds.includes('*')) { - const actions = grantedActions +export function computeConnectionAccess(opts: { + platform: string; + envelope: AccessEnvelope | null; + permissions: PermissionLevel; + localActionIds: string[]; + resolved: ResolvedAllowedAction[]; +}): ConnectionAccess { + const { platform, envelope, permissions, localActionIds, resolved } = opts; + if (envelope === null) return { policy: 'actions', actions: [] }; + + const localAllowlist = localActionIds.includes('*') ? null : localActionIds; + + if (envelope.actionIds !== null || localAllowlist !== null) { + const serverIds = envelope.actionIds; + const actions = resolved .filter(a => a.platform === platform) + .filter(a => serverIds === null || serverIds.includes(a.actionId)) + .filter(a => localAllowlist === null || localAllowlist.includes(a.actionId)) + .filter(a => envelope.methods === null || envelope.methods.includes(a.envelopeMethod)) + .filter(a => isMethodAllowed(a.method, permissions)) .map(({ actionId, title, method }) => ({ actionId, title, method })); return { policy: 'actions', actions }; } - const methods = PERMISSION_METHODS[permissions]; - if (methods !== null) { - return { policy: 'methods', methods }; - } + const localMethods = PERMISSION_METHODS[permissions]; + const methods = + envelope.methods === null + ? localMethods + : localMethods === null + ? envelope.methods + : envelope.methods.filter(m => localMethods.includes(m)); - return { policy: 'full' }; + return methods === null ? { policy: 'full' } : { policy: 'methods', methods }; } /** @@ -96,8 +213,10 @@ export function formatAccess(access: ConnectionAccess, maxActions = 2): string { switch (access.policy) { case 'full': return 'full'; + case 'unknown': + return 'unknown'; case 'methods': - return access.methods.join(', '); + return access.methods.length === 0 ? 'none' : access.methods.join(', '); case 'actions': { if (access.actions.length === 0) return 'none'; const shown = access.actions.slice(0, maxActions).map(a => `${a.title} (${a.method})`); diff --git a/src/lib/api.ts b/src/lib/api.ts index 3b17e7d..f3f32c1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -137,6 +137,14 @@ export class OneApi { return this.request('/users/whoami'); } + /** + * The calling key's access rules as the API sent them, resolved by One from + * the key itself. Interpret the body with `parseKeyAccess`. + */ + async getKeyAccess(): Promise { + return this.request('/access/self'); + } + async validateApiKey(): Promise { try { return await this.whoami(); diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index a039ba3..7a5ddf4 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -134,7 +134,7 @@ Request specific sections: - Always use the **exact action ID** from a find answer — don't guess - Always read **knowledge** before executing any action - Connection keys come from \`one connection list\` — don't hardcode them -- \`connection list\` also reports an \`access\` field per connection (\`full\` / \`methods\` / \`actions\`) — read it before planning so you don't propose an action the access config will reject +- \`connection list\` also reports an \`access\` field per connection (\`full\` / \`methods\` / \`actions\` / \`unknown\`), read live from the key's rules on One — read it before planning so you don't propose an action the key may not run - Skills stay in lockstep with the CLI version automatically — every command checks a \`.one-cli-version\` marker in the canonical skill dir and refreshes the files if the CLI has been upgraded. Check manually with \`one config skills status\`; force a resync with \`one config skills sync\` `; @@ -150,17 +150,18 @@ Always follow this sequence. Never execute without reading the action's document one --agent connection list \`\`\` -Returns platforms, status, connection keys, tags, and an \`access\` field per connection describing what the current access config lets you run there: +Returns platforms, status, connection keys, tags, and an \`access\` field per connection describing what you may run there. It is read live, on every call, from the API key's rules on One (edited on the dashboard), then narrowed by any local \`one config\` restriction: | \`access\` | Meaning | |----------|---------| | \`{"policy": "full"}\` | Every action on the connection | -| \`{"policy": "methods", "methods": ["GET"]}\` | Only actions with these HTTP methods will execute | +| \`{"policy": "methods", "methods": ["GET"]}\` | Only actions of these methods will execute. The key's rules on One judge an action by its CRUD verb (a read sent as POST counts as GET, an update counts as PUT); a local \`one config\` level checks the HTTP method | | \`{"policy": "actions", "actions": [{"actionId", "title", "method"}]}\` | Only these exact actions — use them directly: \`actions load \` reads their docs, no find needed | +| \`{"policy": "unknown"}\` | The key's rules could not be read (\`accessError\` says why) — assume nothing; a disallowed action fails with 403 | -Also present when relevant: \`knowledgeOnly: true\` (execution disabled — read the docs and write code instead), \`unresolvedActionIds\` (allowlisted ids that could not be looked up), and \`accessHint\` (a one-line summary of the restriction). +Also present when relevant: \`knowledgeOnly: true\` (execution disabled — read the docs and write code instead), \`unresolvedActionIds\` (allowed action ids that could not be looked up), \`accessError\` (why access is \`unknown\`), and \`accessHint\` (a one-line summary of the local restriction). -Read \`access\` before planning — it prevents proposing an action the config will reject. Change it with \`one config\`. +Read \`access\` before planning — it prevents proposing an action the key may not run. The key's own rules are changed on the One dashboard; \`one config\` adds a local restriction on top. ### 1b. Delete a Connection diff --git a/src/lib/types.ts b/src/lib/types.ts index 8cc3dec..be476a3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -71,24 +71,49 @@ export interface GrantedAction { } /** - * What the current access config lets you run on one connection, so - * `one list` answers "what can I do here" without a search. Same shape as the - * MCP server's `ConnectionAccess` (`policy` discriminant): + * What the key may run on one connection, so `one list` answers "what can I + * do here" without a search. Same shape as the MCP server's `ConnectionAccess` + * (`policy` discriminant), plus `unknown`: * - `full`: every action on the connection. - * - `methods`: only actions whose HTTP method is in the set. + * - `methods`: only actions whose method is in the set. * - `actions`: only these specific actions. + * - `unknown`: the key's rules on One could not be read, so nothing is claimed. */ export type ConnectionAccess = | { policy: 'full' } | { policy: 'methods'; methods: string[] } - | { policy: 'actions'; actions: GrantedAction[] }; + | { policy: 'actions'; actions: GrantedAction[] } + | { policy: 'unknown' }; /** - * An allowlisted action id resolved to its metadata, including the platform it - * belongs to so it can be bucketed onto the matching connection. + * An action id resolved to its metadata, including the platform it belongs to + * so it can be bucketed onto the matching connection. */ export interface ResolvedAllowedAction extends GrantedAction { platform: string; + /** + * The method One's access rules judge the action by: its CRUD verb + * (`GetMany` is GET, `Update` is PUT, `Custom` is POST), which can differ + * from the HTTP `method` it is sent with. + */ + envelopeMethod: string; +} + +/** One rule on a key, as `GET /v1/access/self` returns it: one connection's envelope. */ +export interface KeyAccessRule { + type: 'connection'; + connectionKey: string; + methods?: string[]; + actionIds?: string[]; +} + +/** + * A key's access restrictions on One. `rules: null` leaves every connection + * bounded only by `methods`; `rules: []` reaches no connection at all. + */ +export interface KeyAccess { + methods: string[] | null; + rules: KeyAccessRule[] | null; } export interface WhoAmIUser { id: string; name: string; email: string } @@ -184,6 +209,8 @@ export interface ActionDetails { knowledge?: string; path: string; method: string; + /** The CRUD-verb method One's access rules judge this action by. */ + envelopeMethod?: string; ioSchema?: IoSchema; /** Platform the action belongs to — used to bucket allowlisted actions onto connections. */ connectionPlatform?: string;