Skip to content
Merged
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
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ Opens your browser, you authorize, done. The CLI polls until the connection is l

### `one list`

List your active connections with their status, connection keys, and what your access config lets you run on each.
List your active connections with their status, connection keys, and the access this key has on each.

```bash
one list
Expand All @@ -199,15 +199,15 @@ 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: what this key may run there. The CLI reads it from One (`GET /v1/access/me`, the access chosen on the consent page or later in the dashboard) and narrows it by any limits you set with [`one config`](#one-config) — the server's answer is the ceiling, local settings can only lower it.

| 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 | Means | `level` |
|--------|-------|---------|
| `{"policy": "full"}` | Every action on the connection | `full` (Full access) |
| `{"policy": "methods", "methods": ["GET"]}` | Only actions with those methods | `read-only`, `read-write` (GET, POST, PUT, PATCH), or `custom` |
| `{"policy": "actions", "actions": [{"actionId": "...", "title": "...", "method": "..."}]}` | Only these specific actions | `custom`, or `none` when empty |

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.
A connection the key does not reach is not listed. 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 @@ -219,14 +219,14 @@ An action allowlist wins over the permission level (and is then method-filtered
"platform": "gmail",
"state": "operational",
"key": "live::gmail::default::abc123",
"access": { "policy": "methods", "methods": ["GET"] }
"access": { "policy": "methods", "methods": ["GET"], "level": "read-only" }
}
],
"accessHint": "Permission level \"read\": only GET actions will execute."
"accessSource": "server"
}
```

`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.
`accessSource` is `"server"` when One reported the key's access and `"local"` when it could not (an older API); then `access` shows only the CLI's own settings, the human table labels the column "Access (CLI settings only)", and `accessHint` says One may allow less. `knowledgeOnly: true` appears when knowledge-only mode is on (execution disabled), and `unresolvedActionIds` lists action ids that could not be looked up.

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
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.4.0",
"version": "2.5.0",
"description": "CLI for managing One",
"type": "module",
"files": [
Expand Down
4 changes: 3 additions & 1 deletion skills/one/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ 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 what One enforces for this key (the choices made on the consent page, or later in the dashboard), narrowed by any limits set with `one config`; `access.level` names it the way the consent page does: `full`, `read-write`, `read-only`, `custom` or `none`. When telling the user their access, use those words (Full access, Read & write, Read only, Custom, No access).

**Read `access` before you plan a workflow** — it saves you from discovering a restriction as a 403 halfway through:

Expand All @@ -57,6 +57,8 @@ Returns connected platforms with their connection keys (needed for execution), p
| `{"policy": "methods", "methods": ["GET"]}` | Only actions with these HTTP methods will execute — don't propose writes |
| `{"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 |

`accessSource` is `"server"` when One reported the key's access, or `"local"` when it could not (the `accessHint` says so) — then `access` reflects only this CLI's settings and One may allow less: tell the user it is unverified rather than calling it full access.

Two 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.
Expand Down
122 changes: 84 additions & 38 deletions src/commands/connection.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
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 { OneApi, TimeoutError, PERMISSION_METHODS } from '../lib/api.js';
import {
resolveAllowedActions,
formatAccess,
serverEnvelope,
localEnvelope,
intersectEnvelopes,
envelopeToAccess,
accessLevel,
} 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 { AccessLevel, Connection, ConnectionAccess, KeyAccessDocument, 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 +182,41 @@ export async function connectionListCommand(options?: { search?: string; limit?:
spinner.start('Loading connections...');

try {
const allConnections = await api.listConnections();

// Filter by access control settings
// What One enforces for this key (the consent page's choices, or the
// dashboard's access editor), fetched beside the listing. An API that
// does not answer it yet leaves the CLI's own settings as the only source,
// and says so.
const [allConnections, serverAccess] = await Promise.all([
api.listConnections(),
api.getOwnAccess().then(
doc => ({ ok: true as const, doc }),
() => ({ ok: false as const, doc: null }),
),
]);

// The CLI's own access settings (`one config`) narrow it further.
const ac = getAccessControlFromAllSources();
const permissions: PermissionLevel = ac.permissions || 'admin';
const allowedKeys = ac.connectionKeys || ['*'];
const actionIds = ac.actionIds || ['*'];
const knowledgeOnly = ac.knowledgeAgent || false;
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 hintText = accessHint(permissions, actionIds, knowledgeOnly);

const envelopeFor = (key: string) =>
intersectEnvelopes(serverEnvelope(serverAccess.doc, key), localEnvelope(permissions, actionIds, allowedKeys, key));
const accessFiltered = allConnections.filter(conn => envelopeFor(conn.key).reachable);

// Resolve every action id either side names, once, through the knowledge
// cache. No network cost unless an action list is in play.
const namedActionIds = new Set<string>(actionIds.includes('*') ? [] : actionIds);
for (const rule of serverAccess.doc?.rules ?? []) for (const id of rule.actionIds ?? []) namedActionIds.add(id);
const resolvedActions = await resolveAllowedActions(api, [...namedActionIds]);
const unresolved = new Set<string>();
const accessFor = (conn: { key: string }): ConnectionAccess => {
const { access, unresolved: missing } = envelopeToAccess(envelopeFor(conn.key), resolvedActions, PERMISSION_METHODS[permissions]);
missing.forEach(id => unresolved.add(id));
return access;
};
const hintText = accessHint(permissions, actionIds, knowledgeOnly, serverAccess.ok ? serverAccess.doc : undefined);

// Filter by search query if provided
const searchQuery = options?.search?.toLowerCase();
Expand All @@ -220,10 +240,11 @@ 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: withLevel(accessFor(conn)),
})),
accessSource: serverAccess.ok ? 'server' : 'local',
...(knowledgeOnly && { knowledgeOnly: true }),
...(unresolvedActionIds.length > 0 && { unresolvedActionIds }),
...(unresolved.size > 0 && { unresolvedActionIds: [...unresolved] }),
...(hintText && { accessHint: hintText }),
...(limited.length < filtered.length && {
hint: `Showing ${limited.length} of ${filtered.length} connections. Use --search <query> to filter by platform or --limit <n> to see more.`,
Expand All @@ -243,6 +264,12 @@ export async function connectionListCommand(options?: { search?: string; limit?:
`Try: ${pc.cyan('one connection list')} to see all connections.`,
'No Results'
);
} else if (serverAccess.doc?.rules && allConnections.length === 0) {
p.note(
`This key reaches no connections: it can search actions and read their docs, but runs none.\n\n` +
`Change it in the dashboard under API keys.`,
'No Connections'
);
} else {
p.note(
`No connections yet.\n\n` +
Expand All @@ -261,13 +288,10 @@ 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);
// Only worth a column when access is actually scoped — an all-"full"
// column is noise.
const hasScopedAccess = rows.some(r => r.access !== 'full');

printTable(
[
Expand All @@ -276,21 +300,23 @@ export async function connectionListCommand(options?: { search?: string; limit?:
{ key: 'state', label: 'Status' },
{ key: 'key', label: 'Connection Key', color: pc.dim },
...(hasTags ? [{ key: 'tags', label: 'Tags', color: pc.dim }] : []),
...(hasScopedAccess ? [{ key: 'access', label: 'Access', color: pc.yellow }] : []),
{ key: 'access', label: serverAccess.ok ? 'Access' : 'Access (CLI settings only)', color: pc.yellow },
],
rows
);

console.log();

if (hintText) {
const lines = [hintText];
if (unresolvedActionIds.length > 0) {
lines.push(
`Could not resolve ${unresolvedActionIds.length} allowlisted action id(s): ${unresolvedActionIds.join(', ')}`
);
if (hintText || unresolved.size > 0) {
const lines = hintText ? [hintText] : [];
if (unresolved.size > 0) {
lines.push(`Could not resolve ${unresolved.size} allowed action id(s): ${[...unresolved].join(', ')}`);
}
p.note(`${wrapText(lines.join('\n'))}\n\nChange it with: ${pc.cyan('one config')}`, 'Access');
p.note(
`${wrapText(lines.join('\n'))}\n\n` +
`This key's access: dashboard → API keys. This CLI's own limits: ${pc.cyan('one config')}`,
'Access'
);
}

if (displayed.length < filtered.length) {
Expand Down Expand Up @@ -396,18 +422,33 @@ export async function connectionDeleteCommand(
function accessHint(
permissions: PermissionLevel,
actionIds: string[],
knowledgeOnly: boolean
knowledgeOnly: boolean,
server: KeyAccessDocument | null | undefined
): string | null {
const parts: string[] = [];

if (server === undefined) {
parts.push(
"One did not report this key's access, so each `access` shows only this CLI's own settings; " +
'One may allow less. Check the key in the dashboard under API keys.'
);
} else if (server?.rules && server.rules.length === 0) {
parts.push('This key reaches no connections: it can read docs and search actions, but no action will execute.');
} else if (server?.rules?.some(r => r.actionIds)) {
parts.push(
"Some connections are action-scoped: their `access.actions` are the only actions you may run there — " +
'use them directly: `actions load <actionId>` reads their docs, no find needed.'
);
}

if (!actionIds.includes('*')) {
parts.push(
"Action-scoped: each connection's `access.actions` are the only actions you may run — " +
"Action-scoped (local settings): each connection's `access.actions` are the only actions you may run — " +
'use them directly: `actions load <actionId>` reads their docs, no find needed.'
);
} else if (permissions !== 'admin') {
const methods = PERMISSION_METHODS[permissions]?.join(', ') ?? '';
parts.push(`Permission level "${permissions}": only ${methods} actions will execute.`);
parts.push(`Permission level "${permissions}" (local settings): only ${methods} actions will execute.`);
}

if (knowledgeOnly) {
Expand All @@ -417,6 +458,11 @@ function accessHint(
return parts.length > 0 ? parts.join(' ') : null;
}

/** The access shape plus the consent page's level, for agents to relay. */
function withLevel(access: ConnectionAccess): ConnectionAccess & { level: AccessLevel } {
return { ...access, level: accessLevel(access) };
}

/** Hard-wrap prose so a long hint doesn't stretch the note box past the terminal. */
function wrapText(text: string, width = 72): string {
return text
Expand Down
7 changes: 5 additions & 2 deletions src/lib/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ describe('computeConnectionAccess', () => {

describe('formatAccess', () => {
it('renders each policy for the table', () => {
assert.equal(formatAccess({ policy: 'full' }), 'full');
assert.equal(formatAccess({ policy: 'full' }), 'Full access');
assert.equal(formatAccess({ policy: 'methods', methods: ['GET'] }), 'Read only');
assert.equal(formatAccess({ policy: 'methods', methods: ['GET', 'POST', 'PUT', 'PATCH'] }), 'Read & write');
assert.equal(formatAccess({ policy: 'methods', methods: ['GET', 'POST'] }), 'GET, POST');
assert.equal(formatAccess({ policy: 'actions', actions: [] }), 'none');
assert.equal(formatAccess({ policy: 'methods', methods: [] }), 'No access');
assert.equal(formatAccess({ policy: 'actions', actions: [] }), 'No access');
});

it('truncates long action lists', () => {
Expand Down
Loading
Loading