Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <actionId>` 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 <actionId>` reads their docs, no find needed.

```jsonc
// one --agent list
Expand All @@ -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.

Expand Down Expand Up @@ -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`

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@withone/cli",
"version": "2.3.2",
"version": "2.4.0",
"description": "CLI for managing One",
"type": "module",
"files": [
Expand Down
10 changes: 6 additions & 4 deletions skills/one/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <actionId>` 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.

Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query>', 'Filter connections by platform name')
.option('-l, --limit <n>', 'Max connections to return (agent mode default: 20)')
.action(async (options) => {
Expand Down
111 changes: 86 additions & 25 deletions src/commands/connection.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (output.isAgentMode()) {
Expand Down Expand Up @@ -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();
Expand All @@ -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 && {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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 —
Expand Down
Loading
Loading