diff --git a/README.md b/README.md index a0c16ee..638318a 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,44 @@ for (const agent of agents) { **Returns** `Array<{ id, name, identifier, isDefault, createdAt }>` +#### `onecli.getEffectiveCredentials(agentId, options?)` + +Which credentials the agent can actually use, and what each one can do under the +published policy. Read-only. + +```ts +const { mode, secrets, connections } = + await onecli.getEffectiveCredentials("agent-id"); +``` + +Replaces the retired `GET /v1/agents/{id}/secrets` and `/connections` reads. +Those returned a stored assignment list; this returns the **effective** set, so a +credential granted by a policy rule appears here even though no assignment row +exists for it. + +#### `onecli.getEffectiveAppPermissions({ provider, agentId? }, options?)` + +What the project's published policy allows for an app, per tool. Omit `agentId` +for the all-agents baseline. The project-scope twin of +`onecli.org.getEffectiveAppPermissions`, and the replacement for the retired +`GET /v1/rules/permissions/{provider}`. + +```ts +const { groups, variesByIdentity } = await onecli.getEffectiveAppPermissions({ + provider: "gmail", +}); +``` + +#### `onecli.getConnectionAgentAccess(connectionId, options?)` + +Which agents can reach a connection, and what each can do with it. Replaces the +retired `GET /v1/connections/{id}/agents`. + +#### `onecli.listAppPermissionDefinitions(options?)` + +Every provider's public tool catalog — the tool ids an `app`-target policy rule +can name. + #### `onecli.ensureAgent(input, options?)` Ensure an agent exists. Creates it if missing, returns normally if it already exists. @@ -416,9 +454,10 @@ await onecli.org.updatePolicyRule(created.id, { enabled: false }); await onecli.org.deletePolicyRule(created.id); await onecli.org.publishPolicy(); // publish anything still staged -// Legacy org rules (deprecated): cloud deployments reject these writes with -// 410 Gone; pre-cutover self-hosted servers still accept them. -const legacyRules = await onecli.org.listRules(); +// What the published policy actually allows for an app, per tool — read-only. +const effective = await onecli.org.getEffectiveAppPermissions({ + provider: "gmail", +}); // Watch manual-approval requests across every project in the org. Each request // carries its own `projectId`, and the decision is routed back to that project. @@ -457,11 +496,7 @@ identified by `metadata.provider` + `metadata.toolId`. | `getPolicyDefault(status?)` / `setPolicyDefault(action, opts?)` | `GET`/`PATCH /v1/org/policy/default` | `OrgPolicyRule` / `PolicyWriteResult` | | `publishPolicy()` | `POST /v1/org/policy/publish` | `PolicyPublishResult` | | `getPolicyLastPublish()` | `GET /v1/org/policy/last-publish` | `PolicyLastPublish \| null` | -| `listRules()` *(legacy)* | `GET /v1/org/rules` | `OrgRule[]` | -| `getRule(id)` *(legacy)* | `GET /v1/org/rules/{id}` | `OrgRule` | -| `createRule(input)` *(deprecated — 410 on cloud)* | `POST /v1/org/rules` | `OrgRule` | -| `updateRule(id, input)` *(deprecated — 410 on cloud)* | `PATCH /v1/org/rules/{id}` | `{ success: boolean }` | -| `deleteRule(id)` *(deprecated — 410 on cloud)* | `DELETE /v1/org/rules/{id}` | `void` | +| `getEffectiveAppPermissions({provider})` | `GET /v1/org/policy/effective-app-permissions` | `EffectiveAppPermissions` | | `configureManualApproval(cb, options?)` | `GET /v1/org/approvals/pending` (long-poll) | `ManualApprovalHandle` | --- diff --git a/src/agents/index.ts b/src/agents/index.ts index 8b0860c..20b13d8 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -5,8 +5,12 @@ import { } from "../errors.js"; import type { Agent, + AppPermissionDefinition, + ConnectionAgentAccess, + EffectiveAppPermissions, CreateAgentInput, CreateAgentResponse, + EffectiveCredentials, EnsureAgentResponse, } from "./types.js"; import type { RequestOptions } from "../request-options.js"; @@ -111,6 +115,105 @@ export class AgentsClient { } }; + /** + * Shared GET for the read-only reflections. Mirrors `listAgents`' error + * handling so every method on this client fails the same way. + */ + private getJson = async ( + path: string, + options?: RequestOptions, + ): Promise => { + const url = `${this.baseUrl}${path}`; + + try { + const res = await fetch(url, { + method: "GET", + headers: this.buildHeaders(options), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!res.ok) { + throw new OneCLIRequestError( + `OneCLI returned ${res.status} ${res.statusText}`, + { url, statusCode: res.status }, + ); + } + + return (await res.json()) as T; + } catch (error) { + if ( + error instanceof OneCLIError || + error instanceof OneCLIRequestError + ) { + throw error; + } + throw toOneCLIError(error); + } + }; + + /** + * Which credentials this agent can actually use, and what each one can do + * under the published policy. + * + * Replaces the retired `GET /v1/agents/:id/{secrets,connections}` reads. Those + * returned a stored assignment list; this returns the EFFECTIVE set, so a + * credential granted by a policy rule appears here even though no assignment + * row exists for it. + */ + getEffectiveCredentials = async ( + agentId: string, + options?: RequestOptions, + ): Promise => + this.getJson( + `/v1/agents/${encodeURIComponent(agentId)}/effective-credentials`, + options, + ); + + /** + * What the project's published policy allows for an app, per tool. Omit + * `agentId` for the all-agents baseline. + * + * The project-scope twin of `org.getEffectiveAppPermissions`, and the + * replacement for the retired `/v1/rules/permissions/:provider`. + */ + getEffectiveAppPermissions = async ( + input: { provider: string; agentId?: string }, + options?: RequestOptions, + ): Promise => { + const query = new URLSearchParams({ provider: input.provider }); + if (input.agentId) query.set("agentId", input.agentId); + return this.getJson( + `/v1/policy/effective-app-permissions?${query.toString()}`, + options, + ); + }; + + /** + * Which agents can reach a connection, and what each can do with it. + * + * Replaces the retired `GET /v1/connections/:id/agents`. + */ + getConnectionAgentAccess = async ( + connectionId: string, + options?: RequestOptions, + ): Promise => + this.getJson( + `/v1/connections/${encodeURIComponent(connectionId)}/effective-agents`, + options, + ); + + /** + * Every provider's public tool catalog — the tool ids an app-target policy + * rule can name. Global data; no project context required. + */ + listAppPermissionDefinitions = async ( + options?: RequestOptions, + ): Promise => + this.getJson( + "/v1/apps/permission-definitions", + options, + ); + /** * Whether an agent with the given identifier already exists in the project. * Swallows lookup failures and returns `false` so callers can fall back to diff --git a/src/agents/types.ts b/src/agents/types.ts index 4d28740..07c39e2 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -31,3 +31,107 @@ export interface EnsureAgentResponse { /** Whether the agent was newly created. `false` if it already existed. */ created: boolean; } + +// ── Read-only policy reflections (project scope) ──────────────────────────── +// +// These replace the retired per-agent equipment reads +// (`/v1/agents/:id/{secrets,connections}`, `/v1/agents/granular-access`, +// `/v1/connections/:id/agents`). They report what the PUBLISHED policy actually +// allows — not a stored assignment list, which no longer exists. + +/** What a credential can do under the rules, once attached. */ +export type CredentialAccessStatus = + | "usable" + | "limited" + | "blocked" + | "unknown"; + +/** Why a credential reaches this agent. An org rule's name is visible only to + * org admins; other viewers get `redacted: true`. */ +export type CredentialProvenance = + | { kind: "rule"; scope: "organization"; redacted: true } + | { + kind: "rule"; + scope: "organization" | "project"; + rule: { logicalId: string; name: string }; + }; + +export type EffectiveCredential = + | { + kind: "secret"; + id: string; + name: string; + host: string; + status: CredentialAccessStatus; + provenance: CredentialProvenance[]; + } + | { + kind: "connection"; + id: string; + label: string | null; + provider: string; + status: CredentialAccessStatus; + provenance: CredentialProvenance[]; + }; + +export interface EffectiveCredentials { + agentId: string; + /** `all` draws the whole fenced pool; `selective` gets only what rules grant. */ + mode: "all" | "selective"; + secrets: EffectiveCredential[]; + connections: EffectiveCredential[]; +} + +/** The headline for one agent against one connection. */ +export type AgentAccessStatus = + | "usable" + | "limited" + | "blocked" + | "none" + | "unknown"; + +/** How the credential attaches — the secondary detail under `access`. */ +export type AgentCredentialStatus = + | { status: "full" } + | { status: "viaRule"; provenance: CredentialProvenance[] } + | { status: "none" }; + +export interface ConnectionAgent { + agentId: string; + name: string; + access: AgentAccessStatus; + credential: AgentCredentialStatus; + /** Null when the provider has no tool catalog to evaluate. */ + decisions: { + allowedTools: number; + totalTools: number; + anyApproval: boolean; + anyRateLimit: boolean; + } | null; +} + +export interface ConnectionAgentAccess { + connectionId: string; + provider: string; + /** False = no permission catalog, so `decisions` is absent by honesty. */ + catalog: boolean; + agents: ConnectionAgent[]; +} + +/** One provider's public tool catalog — ids and labels only; the endpoint + * mapping never leaves the server. */ +export interface AppPermissionDefinition { + provider: string; + groups: { + category: "read" | "write"; + tools: { id: string; name: string; description?: string }[]; + }[]; +} + +export type { + EffectiveAppPermissions, + EffectiveProvenance, + EffectiveTool, + EffectiveToolGroup, + EffectiveToolVerdict, +} from "../org/types.js"; diff --git a/src/client.ts b/src/client.ts index 9d5e492..3de70c8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,8 +12,12 @@ import type { } from "./container/types.js"; import type { Agent, + AppPermissionDefinition, + ConnectionAgentAccess, CreateAgentInput, + EffectiveAppPermissions, CreateAgentResponse, + EffectiveCredentials, EnsureAgentResponse, } from "./agents/types.js"; import type { @@ -123,6 +127,52 @@ export class OneCLI { /** * Ensure an agent exists. Creates it if missing, returns normally if it already exists. */ + /** + * Which credentials this agent can actually use, and what each one can do + * under the published policy. Read-only. + * + * Replaces the retired `GET /v1/agents/:id/{secrets,connections}` reads — + * those returned a stored assignment list, this returns the EFFECTIVE set. + */ + getEffectiveCredentials = ( + agentId: string, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.getEffectiveCredentials(agentId, options); + }; + + /** + * What the project's published policy allows for an app, per tool. + * Replaces the retired `GET /v1/rules/permissions/:provider`. + */ + getEffectiveAppPermissions = ( + input: { provider: string; agentId?: string }, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.getEffectiveAppPermissions(input, options); + }; + + /** + * Which agents can reach a connection, and what each can do with it. + * Replaces the retired `GET /v1/connections/:id/agents`. + */ + getConnectionAgentAccess = ( + connectionId: string, + options?: RequestOptions, + ): Promise => { + return this.agentsClient.getConnectionAgentAccess(connectionId, options); + }; + + /** + * Every provider's public tool catalog — the tool ids an `app`-target policy + * rule can name. + */ + listAppPermissionDefinitions = ( + options?: RequestOptions, + ): Promise => { + return this.agentsClient.listAppPermissionDefinitions(options); + }; + ensureAgent = ( input: CreateAgentInput, options?: RequestOptions, diff --git a/src/index.ts b/src/index.ts index 494b68d..4a7da16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,17 @@ export type { CreateAgentInput, CreateAgentResponse, EnsureAgentResponse, + // Read-only policy reflections — the replacements for the retired + // per-agent equipment reads. + AgentAccessStatus, + AgentCredentialStatus, + AppPermissionDefinition, + ConnectionAgent, + ConnectionAgentAccess, + CredentialAccessStatus, + CredentialProvenance, + EffectiveCredential, + EffectiveCredentials, } from "./agents/types.js"; export type { ApprovalRequest, @@ -39,14 +50,12 @@ export type { ConnectOrgAppInput, GetOrgAuthorizeUrlOptions, OrgConnection, - OrgRule, - OrgRuleAction, - OrgRuleMethod, - OrgRuleRateLimitWindow, - OrgRuleCondition, - CreateOrgRuleInput, - UpdateOrgRuleInput, OrgPolicyRule, + EffectiveAppPermissions, + EffectiveTool, + EffectiveToolGroup, + EffectiveToolVerdict, + EffectiveProvenance, PolicyRuleAction, PolicyRuleStatus, PolicyRuleIdentity, diff --git a/src/org/index.ts b/src/org/index.ts index 064dcaa..a910243 100644 --- a/src/org/index.ts +++ b/src/org/index.ts @@ -12,11 +12,10 @@ import type { import type { ConnectOrgAppInput, CreateOrgPolicyRuleInput, - CreateOrgRuleInput, GetOrgAuthorizeUrlOptions, OrgConnection, + EffectiveAppPermissions, OrgPolicyRule, - OrgRule, PolicyLastPublish, PolicyPublishResult, PolicyRuleAction, @@ -24,7 +23,6 @@ import type { PolicyWriteOptions, PolicyWriteResult, UpdateOrgPolicyRuleInput, - UpdateOrgRuleInput, } from "./types.js"; const CLOUD_OR_ENTERPRISE_HINT = @@ -249,60 +247,16 @@ export class OrgClient { }; /** - * List the organization's policy rules (applied to every agent in the org). + * The org's effective app permissions for a provider — what the published + * policy actually allows, per tool. Read-only; the replacement for the + * retired `/v1/org/rules/permissions/:provider` surface. */ - listRules = async (): Promise => { - return this.request("GET", "/v1/org/rules"); - }; - - /** - * Get a single organization rule. - */ - getRule = async (ruleId: string): Promise => { - return this.request( + getEffectiveAppPermissions = async ( + input: { provider: string }, + ): Promise => { + return this.request( "GET", - `/v1/org/rules/${encodeURIComponent(ruleId)}`, - ); - }; - - /** - * Create an organization rule. - * - * @deprecated Cloud deployments reject this write with 410 Gone — use - * {@link createPolicyRule}. Pre-cutover self-hosted servers still accept it. - */ - createRule = async (input: CreateOrgRuleInput): Promise => { - return this.request("POST", "/v1/org/rules", input); - }; - - /** - * Update an organization rule. Nullable fields accept an explicit `null` - * to clear the stored value. - * - * @deprecated Cloud deployments reject this write with 410 Gone — use - * {@link updatePolicyRule}. Pre-cutover self-hosted servers still accept it. - */ - updateRule = async ( - ruleId: string, - input: UpdateOrgRuleInput, - ): Promise<{ success: boolean }> => { - return this.request<{ success: boolean }>( - "PATCH", - `/v1/org/rules/${encodeURIComponent(ruleId)}`, - input, - ); - }; - - /** - * Delete an organization rule. - * - * @deprecated Cloud deployments reject this write with 410 Gone — use - * {@link deletePolicyRule}. Pre-cutover self-hosted servers still accept it. - */ - deleteRule = async (ruleId: string): Promise => { - await this.request( - "DELETE", - `/v1/org/rules/${encodeURIComponent(ruleId)}`, + `/v1/org/policy/effective-app-permissions?provider=${encodeURIComponent(input.provider)}`, ); }; diff --git a/src/org/types.ts b/src/org/types.ts index 7e81c1f..14563e5 100644 --- a/src/org/types.ts +++ b/src/org/types.ts @@ -296,3 +296,66 @@ export interface PolicyLastPublish { appliedAt: string; appliedBy: { name: string | null; email: string } | null; } + +// ── Effective app permissions (the read-only policy reflection) ───────────── +// +// Replaces the retired `/v1/org/rules/permissions/:provider` surface. This is a +// REFLECTION, not an editor: it reports what the org's published policy actually +// allows per tool. Author changes with the `*PolicyRule` methods. + +/** What the published policy does to one tool. + * + * `mixed` means the tool's variants — or a group's tools — disagree. + * `unmanaged` means no rule applies and the traffic passes through under the + * enforce-deny carve (no credential attached, or an LLM host). + * + * A rate limit is NOT a verdict: it rides `rateLimit` / `rateLimitWindow` on an + * `allow`. */ +export type EffectiveToolVerdict = + | "allow" + | "approval" + | "block" + | "mixed" + | "unmanaged"; + +/** Which rule decided, when one did. An org rule's NAME is visible only to org + * admins; other viewers get `redacted: true` with the name withheld. */ +export type EffectiveProvenance = + | { scope: "organization"; redacted: true } + | { + scope: "organization" | "project"; + rule: { logicalId: string; name: string }; + }; + +export interface EffectiveTool { + toolId: string; + verdict: EffectiveToolVerdict; + rateLimit: number | null; + rateLimitWindow: string | null; + /** Null when the tool's variants disagree, or when it is allowed purely by an + * allow-posture default rather than a specific rule. */ + decidedBy: EffectiveProvenance | null; +} + +export interface EffectiveToolGroup { + category: "read" | "write"; + /** The rollup: the tools' common verdict, else `mixed`. */ + verdict: EffectiveToolVerdict; + tools: EffectiveTool[]; +} + +/** The per-tool reflection for one app. Structurally identical at project and + * organization scope — `basis.scope` reports which produced it. */ +export interface EffectiveAppPermissions { + provider: string; + basis: { + /** Null = the agent-less baseline; only any-identity rules are applied. */ + agentId: string | null; + credentialAttached: boolean; + scope: "organization" | "project"; + }; + /** Count of identity-scoped rules the baseline view cannot show, because they + * match only specific agents or groups. */ + variesByIdentity: number; + groups: EffectiveToolGroup[]; +} diff --git a/test/agents/client.test.ts b/test/agents/client.test.ts index c0ad614..8883f43 100644 --- a/test/agents/client.test.ts +++ b/test/agents/client.test.ts @@ -433,3 +433,113 @@ describe("AgentsClient", () => { }); }); }); + +describe("read-only policy reflections", () => { + // These replace the retired per-agent equipment reads. The property that + // matters is the PATH — a wrong one silently returns another resource's shape + // or a 404, and the old paths are 410 now. + let spy: ReturnType; + afterEach(() => spy?.mockRestore()); + + const client = () => + new AgentsClient("http://localhost:3000", "oc_test", 5000, null); + + // A Response body can only be read once, so mint a fresh one per call — + // mockResolvedValue would hand the same exhausted object to a second call. + const arm = (body: unknown) => { + spy = vi + .spyOn(globalThis, "fetch") + .mockImplementation( + async () => new Response(JSON.stringify(body), { status: 200 }), + ); + }; + + it("getEffectiveCredentials hits the agent reflection and decodes both arms", async () => { + arm({ + agentId: "a1", + mode: "selective", + secrets: [ + { + kind: "secret", + id: "s1", + name: "Anthropic key", + host: "api.anthropic.com", + status: "usable", + provenance: [ + { + kind: "rule", + scope: "project", + rule: { logicalId: "l1", name: "Anthropic key" }, + }, + ], + }, + ], + connections: [], + }); + + const result = await client().getEffectiveCredentials("a1"); + + expect(result.mode).toBe("selective"); + expect(result.secrets[0]!.kind).toBe("secret"); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/agents/a1/effective-credentials", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("getConnectionAgentAccess hits the connection reflection", async () => { + arm({ connectionId: "c1", provider: "github", catalog: true, agents: [] }); + + const result = await client().getConnectionAgentAccess("c1"); + + expect(result.provider).toBe("github"); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/connections/c1/effective-agents", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("listAppPermissionDefinitions hits the catalog", async () => { + arm([{ provider: "gmail", groups: [] }]); + + const result = await client().listAppPermissionDefinitions(); + + expect(result[0]!.provider).toBe("gmail"); + expect(spy).toHaveBeenCalledWith( + "http://localhost:3000/v1/apps/permission-definitions", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("getEffectiveAppPermissions hits the PROJECT reflection, agentId optional", async () => { + arm({ provider: "gmail", basis: { agentId: null, credentialAttached: true, scope: "project" }, variesByIdentity: 0, groups: [] }); + await client().getEffectiveAppPermissions({ provider: "gmail" }); + expect(spy.mock.calls[0]![0]).toBe( + "http://localhost:3000/v1/policy/effective-app-permissions?provider=gmail", + ); + + spy.mockClear(); + await client().getEffectiveAppPermissions({ provider: "gmail", agentId: "a1" }); + expect(spy.mock.calls[0]![0]).toBe( + "http://localhost:3000/v1/policy/effective-app-permissions?provider=gmail&agentId=a1", + ); + }); + + it("url-encodes ids so a slash cannot escape the path", async () => { + arm({}); + await client().getEffectiveCredentials("a/../b"); + expect(spy.mock.calls[0]![0]).toBe( + "http://localhost:3000/v1/agents/a%2F..%2Fb/effective-credentials", + ); + }); + + it("maps a non-2xx to OneCLIRequestError with the status", async () => { + spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("gone", { status: 410 })); + + await expect(client().getEffectiveCredentials("a1")).rejects.toThrow( + OneCLIRequestError, + ); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index d354057..a008086 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -18,3 +18,16 @@ describe("package exports", () => { expect(AgentsClient).toBeDefined(); }); }); + +describe("the retired legacy rule types", () => { + it("are no longer part of the public surface", async () => { + // Cloud PR #709 deleted /v1/org/rules, so these types describe a shape the + // API can no longer return. Shipping them would imply the methods still + // exist. The 3.0.0 breaking change is exactly this removal. + const mod = await import("../src/index.js"); + const exported = Object.keys(mod); + for (const gone of ["OrgRule", "CreateOrgRuleInput", "UpdateOrgRuleInput"]) { + expect(exported).not.toContain(gone); + } + }); +}); diff --git a/test/org/client.test.ts b/test/org/client.test.ts index 5364977..2715ee7 100644 --- a/test/org/client.test.ts +++ b/test/org/client.test.ts @@ -179,129 +179,81 @@ describe("OrgClient connections", () => { }); }); -describe("OrgClient rules", () => { - it("creates an org rule", async () => { - const fetchSpy = vi - .spyOn(globalThis, "fetch") - .mockResolvedValue( - new Response(JSON.stringify({ id: "r1", name: "block sends" }), { - status: 201, - }), - ); - - const rule = await client().createRule({ - name: "block sends", - hostPattern: "gmail.googleapis.com", - action: "block", - enabled: true, - }); - - expect(rule.id).toBe("r1"); - expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:3000/v1/org/rules", - expect.objectContaining({ method: "POST" }), - ); - }); - - it("decodes mixed rule listings: masked app-permission rules + custom rules", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( +describe("OrgClient effective app permissions", () => { + it("reads the org reflection on the canonical path", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( - JSON.stringify([ - { - id: "app-1", - name: "Gmail: Send email", - action: "manual_approval", - enabled: true, - rateLimit: null, - rateLimitWindow: null, - scope: "organization", - metadata: { - source: "app_permission", - provider: "gmail", - toolId: "send_email", + JSON.stringify({ + provider: "gmail", + basis: { agentId: null, credentialAttached: true, scope: "organization" }, + variesByIdentity: 2, + groups: [ + { + category: "write", + verdict: "mixed", + tools: [ + { + toolId: "send_email", + verdict: "blocked", + rateLimit: null, + rateLimitWindow: null, + decidedBy: { + scope: "organization", + rule: { logicalId: "l1", name: "No sends" }, + }, + }, + ], }, - createdAt: "2026-07-04T00:00:00Z", - }, - { - id: "custom-1", - name: "Block deletes", - hostPattern: "api.example.com", - pathPattern: "/v1/*", - method: "DELETE", - action: "block", - enabled: true, - rateLimit: null, - rateLimitWindow: null, - scope: "organization", - metadata: null, - createdAt: "2026-07-04T00:00:00Z", - }, - ]), + ], + }), { status: 200 }, ), ); - const rules = await client().listRules(); - - const appRule = rules[0]!; - expect(appRule.hostPattern).toBeUndefined(); - expect(appRule.pathPattern).toBeUndefined(); - expect(appRule.method).toBeUndefined(); - expect(appRule.metadata).toEqual({ - source: "app_permission", + const result = await client().getEffectiveAppPermissions({ provider: "gmail", - toolId: "send_email", }); - const custom = rules[1]!; - expect(custom.hostPattern).toBe("api.example.com"); - expect(custom.pathPattern).toBe("/v1/*"); - expect(custom.method).toBe("DELETE"); + expect(result.provider).toBe("gmail"); + expect(result.groups[0]!.tools[0]!.verdict).toBe("blocked"); + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/v1/org/policy/effective-app-permissions?provider=gmail", + expect.objectContaining({ method: "GET" }), + ); }); - it("lists, gets, updates, and deletes rules on the canonical paths", async () => { + it("url-encodes the provider", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") - .mockImplementation(async () => { - const call = fetchSpy.mock.calls.at(-1); - const isDelete = - (call?.[1] as { method?: string } | undefined)?.method === "DELETE"; - return isDelete - ? new Response(null, { status: 204 }) - : new Response(JSON.stringify({ success: true }), { status: 200 }); - }); - - await client().updateRule("r1", { enabled: false, pathPattern: null }); - await client().listRules(); - await client().getRule("r1"); - await client().deleteRule("r1"); + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); - expect(fetchSpy).toHaveBeenNthCalledWith( - 1, - "http://localhost:3000/v1/org/rules/r1", - expect.objectContaining({ - method: "PATCH", - body: JSON.stringify({ enabled: false, pathPattern: null }), - }), - ); - expect(fetchSpy).toHaveBeenNthCalledWith( - 2, - "http://localhost:3000/v1/org/rules", - expect.objectContaining({ method: "GET" }), - ); - expect(fetchSpy).toHaveBeenNthCalledWith( - 3, - "http://localhost:3000/v1/org/rules/r1", - expect.objectContaining({ method: "GET" }), - ); - expect(fetchSpy).toHaveBeenNthCalledWith( - 4, - "http://localhost:3000/v1/org/rules/r1", - expect.objectContaining({ method: "DELETE" }), + await client().getEffectiveAppPermissions({ provider: "a/b?c" }); + + expect(fetchSpy.mock.calls[0]![0]).toBe( + "http://localhost:3000/v1/org/policy/effective-app-permissions?provider=a%2Fb%3Fc", ); }); }); +describe("the retired legacy rule surface", () => { + it("is gone from the client — those endpoints answer 410 now", () => { + // Cloud PR #709 deleted /v1/org/rules. Keeping methods that can only throw + // would be worse than removing them, so this pins their absence: the + // replacement is the *PolicyRule family plus the reflection above. + const c = client() as unknown as Record; + for (const gone of [ + "listRules", + "getRule", + "createRule", + "updateRule", + "deleteRule", + ]) { + expect(c[gone]).toBeUndefined(); + } + expect(typeof c.listPolicyRules).toBe("function"); + expect(typeof c.getEffectiveAppPermissions).toBe("function"); + }); +}); describe("OneCLI facade", () => { it("exposes the org sub-client", () => { const onecli = new OneCLI({