From 45f1ba07ee8b7cf686608ab37a696e1aebabe630 Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Wed, 29 Jul 2026 09:31:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(agents):=20grants=20=E2=80=94=20the=20per-?= =?UTF-8?q?agent=20credential=20attach=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the CLI with the server's attach model: per-agent access is now written as grants (attach a connection or secret to an agent, optionally with per-tool --allow/--ask lists) instead of secret modes, assignment lists, and project-scope policy rules. - new: 'agents grants list|attach-connection|detach-connection| attach-secret|detach-secret', 'apps connections grants', and 'agents list --with-grants' - internal/api: the grants client (doProject + PathEscape; custom grants always send both arrays; the secret attach is a no-body PUT; detaches are 204) - retirement pass: the legacy 'rules'/'org rules' families, 'org settings', 'agents secrets|set-secrets|set-secret-mode|granular-access| connections', and all project-scope 'policy' writes now say RETIRED in every help surface, each pointing at its replacement; the 410 action hint splits project (grants) from org (org policy) - help_test.go: two-direction drift test pinning help.go to the live Kong tree, plus RETIRED-marker parity across both help surfaces - README + skills/onecli/SKILL.md: grants workflow, grant invariants, and the gateway's multi-account 409 protocol Co-Authored-By: Claude Fable 5 --- README.md | 75 ++++---- cmd/onecli/agents.go | 37 ++-- cmd/onecli/apps.go | 1 + cmd/onecli/grants.go | 288 ++++++++++++++++++++++++++++++ cmd/onecli/grants_test.go | 89 +++++++++ cmd/onecli/help.go | 87 ++++++--- cmd/onecli/help_test.go | 108 +++++++++++ cmd/onecli/main.go | 2 +- cmd/onecli/org.go | 2 +- cmd/onecli/org_rules.go | 18 +- cmd/onecli/policy.go | 26 +-- cmd/onecli/resources.go | 4 +- cmd/onecli/rules.go | 18 +- internal/api/grants.go | 194 ++++++++++++++++++++ internal/api/grants_test.go | 347 ++++++++++++++++++++++++++++++++++++ skills/onecli/SKILL.md | 118 ++++++++---- 16 files changed, 1277 insertions(+), 137 deletions(-) create mode 100644 cmd/onecli/grants.go create mode 100644 cmd/onecli/grants_test.go create mode 100644 cmd/onecli/help_test.go create mode 100644 internal/api/grants.go create mode 100644 internal/api/grants_test.go diff --git a/README.md b/README.md index ca01d05..91d137d 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,39 @@ onecli agents create --name "My Agent" --identifier my-agent ### Agents ``` -onecli agents list List all agents +onecli agents list [--with-grants] List all agents (optionally with grants) onecli agents get-default Get the default agent onecli agents create --name X --identifier Y Create a new agent onecli agents delete --id X Delete an agent onecli agents rename --id X --name Y Rename an agent onecli agents regenerate-token --id X Regenerate access token onecli agents credentials --id X What the agent can use (read-only) -onecli agents set-secret-mode --id X --mode selective Set secret mode -onecli agents secrets --id X RETIRED — see 'agents credentials' -onecli agents set-secrets --id X --secret-ids a,b RETIRED — grant with a policy rule +onecli agents set-secret-mode --id X --mode selective RETIRED — see 'agents grants' +onecli agents secrets --id X RETIRED — see 'agents grants list' +onecli agents set-secrets --id X --secret-ids a,b RETIRED — see 'agents grants attach-secret' ``` +### Grants (the attach model) + +A grant attaches one credential to one agent — the only project-scope policy +write. An agent starts with nothing and uses exactly what its grants attach. +Grants are attach INTENT; `agents credentials` is the EFFECTIVE view with +organization guardrails applied. Changes publish immediately. + +``` +onecli agents grants list --id X The agent's attached connections + secrets +onecli agents grants attach-connection --id X \ + --connection-id Y [--allow t1,t2] [--ask t3] Attach (no flags = full access) +onecli agents grants detach-connection --id X --connection-id Y +onecli agents grants attach-secret --id X --secret-id Y +onecli agents grants detach-secret --id X --secret-id Y +onecli apps connections grants --id Y Which agents a connection is granted to +``` + +Tool IDs come from `onecli apps permission-definition --provider `. A tool +cannot be in both `--allow` and `--ask`; a grant with every tool set to Never is +a detach (the server rejects it). `--ask` requires the approvals feature. + ### Secrets ``` @@ -51,21 +72,19 @@ onecli secrets delete --id X Delete a secret ### Rules (legacy model) -Cloud deployments reject these writes (410) — use the `policy` family below. -Pre-cutover self-hosted servers still accept them. +Updated servers answer **410 Gone** for every `rules` subcommand. Project access +is granted per agent (`agents grants`); org guardrails live under `org policy`. +The commands remain only for pre-cutover self-hosted servers and retire when +those do. ``` -onecli rules list RETIRED — see 'policy rules list' -onecli rules get --id X RETIRED — see 'policy rules get' -onecli rules create --name X --host-pattern Y ... RETIRED — see 'policy rules create' -onecli rules update --id X [--action block] ... RETIRED — see 'policy rules update' -onecli rules delete --id X RETIRED — see 'policy rules delete' +onecli rules list RETIRED — see 'agents grants' +onecli rules get --id X RETIRED — see 'agents grants' +onecli rules create --name X --host-pattern Y ... RETIRED — see 'agents grants attach-connection' +onecli rules update --id X [--action block] ... RETIRED — see 'agents grants attach-connection' +onecli rules delete --id X RETIRED — see 'agents grants detach-connection' ``` -Every `rules` subcommand answers **410 Gone** on an updated server — the policy -engine replaced them. They remain only for pre-cutover self-hosted servers, -where `policy` is not yet available, and retire when those do. - ### Policy reflections (read-only) What the PUBLISHED policy actually allows — the replacements for the retired @@ -79,24 +98,20 @@ onecli agents credentials --id X What one agent can u onecli apps connections agent-access --id X Who can reach a connection ``` -### Policy (the policy engine) +### Policy (project scope — RETIRED) -Rules stage into a DRAFT and enforce on publish. Writes auto-publish when the -draft has no other staged changes (`--no-publish` stages; `--publish-all` -publishes everything). +Updated servers answer **410 Gone** for project-scope policy authoring: project +rules are compiled from agent credential grants, never authored directly. Use +`agents grants` for project access and `org policy` (below, under Organization) +for org guardrails. The commands remain only for self-hosted servers that +predate the attach model. ``` -onecli policy rules list [--status published] List rules (draft or the enforced set) -onecli policy rules get --id X Get a DRAFT rule -onecli policy rules create --name X --action allow \ - --targets '[{"kind":"network","hostPattern":"api.example.com"}]' -onecli policy rules update --id X [--action block] Update a DRAFT rule -onecli policy rules delete --id X Delete a DRAFT rule -onecli policy rules reorder --ordered-ids '[...]' Reorder (every draft id exactly once) -onecli policy default get Show the terminal Default Rule -onecli policy default set --action allow|block Set the Default Rule's action -onecli policy publish Publish the whole staged draft -onecli policy status Staged diff + last publish +onecli policy rules ... RETIRED — see 'agents grants' +onecli policy default ... RETIRED — see 'org policy default' +onecli policy publish RETIRED — grants publish immediately +onecli policy status RETIRED — see 'agents grants list' +onecli policy effective-permissions --provider gmail Per-tool verdicts (still live, read-only) ``` ### Organization diff --git a/cmd/onecli/agents.go b/cmd/onecli/agents.go index 82dbac7..4e4f364 100644 --- a/cmd/onecli/agents.go +++ b/cmd/onecli/agents.go @@ -19,11 +19,12 @@ type AgentsCmd struct { Delete AgentsDeleteCmd `cmd:"" help:"Delete an agent."` Rename AgentsRenameCmd `cmd:"" help:"Rename an agent."` RegenerateToken AgentsRegenerateTokenCmd `cmd:"" name:"regenerate-token" help:"Regenerate an agent's access token."` - Secrets AgentsSecretsCmd `cmd:"" help:"List secrets assigned to an agent."` - SetSecrets AgentsSetSecretsCmd `cmd:"" name:"set-secrets" help:"Set secrets assigned to an agent."` - SetSecretMode AgentsSetSecretModeCmd `cmd:"" name:"set-secret-mode" help:"Set an agent's secret mode."` - GranularAccess AgentsGranularAccessCmd `cmd:"" name:"granular-access" help:"Show per-agent granular-access policies across the project."` - Connections AgentsConnectionsCmd `cmd:"" help:"Manage an agent's app-connection assignments (RETIRED — updated servers answer 410; see 'onecli agents credentials')."` + Secrets AgentsSecretsCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'agents grants list' (attached) or 'agents credentials' (effective)."` + SetSecrets AgentsSetSecretsCmd `cmd:"" name:"set-secrets" help:"RETIRED — updated servers answer 410 Gone. Attach with 'agents grants attach-secret'."` + SetSecretMode AgentsSetSecretModeCmd `cmd:"" name:"set-secret-mode" help:"RETIRED — updated servers answer 410 Gone; agents are always selective. Attach credentials with 'agents grants'."` + GranularAccess AgentsGranularAccessCmd `cmd:"" name:"granular-access" help:"RETIRED — updated servers answer 410 Gone. Resource scoping rides the granting rule's conditions; see 'agents credentials'."` + Connections AgentsConnectionsCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'agents grants' to attach connections."` + Grants AgentsGrantsCmd `cmd:"" help:"Manage the agent's credential grants (attach model): attach/detach app connections, secrets, and LLM keys."` Credentials AgentsCredentialsCmd `cmd:"" help:"Show which credentials the agent can use and what each one can do (read-only)."` } @@ -73,8 +74,8 @@ func (c *AgentsGranularAccessCmd) Run(out *output.Writer) error { // AgentsConnectionsCmd is the `onecli agents connections` command group. type AgentsConnectionsCmd struct { - Get AgentsConnectionsGetCmd `cmd:"" help:"Get an agent's app-connection assignments."` - Set AgentsConnectionsSetCmd `cmd:"" help:"Replace an agent's app-connection assignments (raw JSON)."` + Get AgentsConnectionsGetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'agents grants list'."` + Set AgentsConnectionsSetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Attach with 'agents grants attach-connection'."` } // AgentsConnectionsGetCmd is `onecli agents connections get`. @@ -128,10 +129,11 @@ func (c *AgentsConnectionsSetCmd) Run(out *output.Writer) error { // AgentsListCmd is `onecli agents list`. type AgentsListCmd struct { - Project string `optional:"" short:"p" help:"Project slug."` - Fields string `optional:"" help:"Comma-separated list of fields to include in output."` - Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line."` - Max int `optional:"" default:"20" help:"Maximum number of results to return."` + Project string `optional:"" short:"p" help:"Project slug."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` + Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line."` + Max int `optional:"" default:"20" help:"Maximum number of results to return."` + WithGrants bool `optional:"" name:"with-grants" help:"Include each agent's grants summary (attached connections, secrets, LLM keys)."` } func (c *AgentsListCmd) Run(out *output.Writer) error { @@ -143,6 +145,19 @@ func (c *AgentsListCmd) Run(out *output.Writer) error { if err != nil { return err } + if c.WithGrants { + agents, err := client.ListAgentsWithGrantsSummary(newContext(), project) + if err != nil { + return err + } + if c.Max > 0 && len(agents) > c.Max { + agents = agents[:c.Max] + } + if c.Quiet != "" { + return out.WriteQuiet(agents, c.Quiet) + } + return out.WriteFiltered(agents, c.Fields) + } agents, err := client.ListAgents(newContext(), project) if err != nil { return err diff --git a/cmd/onecli/apps.go b/cmd/onecli/apps.go index d3c4b90..ffc7c79 100644 --- a/cmd/onecli/apps.go +++ b/cmd/onecli/apps.go @@ -36,6 +36,7 @@ type AppsConnectionsCmd struct { List AppsConnectionsListCmd `cmd:"" help:"List app connections, optionally filtered by provider."` Rename AppsConnectionsRenameCmd `cmd:"" help:"Rename an app connection."` AgentAccess AppsConnectionsAgentAccessCmd `cmd:"" name:"agent-access" help:"Show which agents can reach a connection, and what each can do (read-only)."` + Grants AppsConnectionsGrantsCmd `cmd:"" help:"Show which agents a connection is granted to (attach intent, read-only)."` } // AppsConnectionsListCmd is `onecli apps connections list`. diff --git a/cmd/onecli/grants.go b/cmd/onecli/grants.go new file mode 100644 index 0000000..7331491 --- /dev/null +++ b/cmd/onecli/grants.go @@ -0,0 +1,288 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/onecli/onecli-cli/internal/api" + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// The attach model's write path: a grant attaches one credential to one agent +// and is the only project-scope policy writer (the server compiles grants +// into rules). 'agents grants' is attach INTENT; 'agents credentials' stays +// the EFFECTIVE view with organization guardrails applied. + +// AgentsGrantsCmd is the `onecli agents grants` group. +type AgentsGrantsCmd struct { + List AgentsGrantsListCmd `cmd:"" help:"Show the agent's grants: attached app connections (with per-tool access) and secrets."` + AttachConnection AgentsGrantsAttachConnectionCmd `cmd:"" name:"attach-connection" help:"Attach an app connection to the agent (full access by default; --allow/--ask set per-tool access)."` + DetachConnection AgentsGrantsDetachConnectionCmd `cmd:"" name:"detach-connection" help:"Detach an app connection from the agent."` + AttachSecret AgentsGrantsAttachSecretCmd `cmd:"" name:"attach-secret" help:"Attach a secret or LLM key to the agent."` + DetachSecret AgentsGrantsDetachSecretCmd `cmd:"" name:"detach-secret" help:"Detach a secret from the agent."` +} + +// AgentsGrantsListCmd is `onecli agents grants list`. +type AgentsGrantsListCmd struct { + ID string `required:"" help:"ID of the agent."` + Project string `optional:"" short:"p" help:"Project slug."` + Fields string `optional:"" help:"Comma-separated fields to include."` + Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line."` +} + +func (c *AgentsGrantsListCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid agent ID: %w", err) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + result, err := client.GetAgentGrants(newContext(), project, c.ID) + if err != nil { + return err + } + if c.Quiet != "" { + return out.WriteQuiet(result, c.Quiet) + } + return out.WriteFiltered(result, c.Fields) +} + +// AgentsGrantsAttachConnectionCmd is `onecli agents grants attach-connection`. +type AgentsGrantsAttachConnectionCmd struct { + ID string `required:"" help:"ID of the agent."` + ConnectionID string `required:"" name:"connection-id" help:"ID of the app connection to attach."` + Allow string `optional:"" help:"Comma-separated tool IDs to always allow (from 'apps permission-definition'). With --ask, unlisted tools are blocked."` + Ask string `optional:"" help:"Comma-separated tool IDs that require manual approval before running."` + Json string `optional:"" help:"Raw JSON grant body: {\"access\":\"full\"} or {\"access\":\"custom\",\"allow\":[...],\"ask\":[...]} (do not combine with --allow/--ask)."` + Project string `optional:"" short:"p" help:"Project slug."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AgentsGrantsAttachConnectionCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid agent ID: %w", err) + } + if err := validate.ResourceID(c.ConnectionID); err != nil { + return fmt.Errorf("invalid connection ID: %w", err) + } + input, err := buildConnectionGrantInput(c.Json, c.Allow, c.Ask) + if err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would attach the connection to the agent", map[string]any{ + "id": c.ID, "connection_id": c.ConnectionID, "grant": input, + }) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + result, err := client.SetAgentConnectionGrant(newContext(), project, c.ID, c.ConnectionID, input) + if err != nil { + return err + } + return out.Write(result) +} + +// AgentsGrantsDetachConnectionCmd is `onecli agents grants detach-connection`. +type AgentsGrantsDetachConnectionCmd struct { + ID string `required:"" help:"ID of the agent."` + ConnectionID string `required:"" name:"connection-id" help:"ID of the app connection to detach."` + Project string `optional:"" short:"p" help:"Project slug."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AgentsGrantsDetachConnectionCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid agent ID: %w", err) + } + if err := validate.ResourceID(c.ConnectionID); err != nil { + return fmt.Errorf("invalid connection ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would detach the connection from the agent", map[string]any{ + "id": c.ID, "connection_id": c.ConnectionID, + }) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + if err := client.RemoveAgentConnectionGrant(newContext(), project, c.ID, c.ConnectionID); err != nil { + return err + } + return out.Write(map[string]string{ + "status": "detached", "agentId": c.ID, "connectionId": c.ConnectionID, + }) +} + +// AgentsGrantsAttachSecretCmd is `onecli agents grants attach-secret`. +type AgentsGrantsAttachSecretCmd struct { + ID string `required:"" help:"ID of the agent."` + SecretID string `required:"" name:"secret-id" help:"ID of the secret (or LLM key) to attach."` + Project string `optional:"" short:"p" help:"Project slug."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AgentsGrantsAttachSecretCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid agent ID: %w", err) + } + if err := validate.ResourceID(c.SecretID); err != nil { + return fmt.Errorf("invalid secret ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would attach the secret to the agent", map[string]any{ + "id": c.ID, "secret_id": c.SecretID, + }) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + result, err := client.SetAgentSecretGrant(newContext(), project, c.ID, c.SecretID) + if err != nil { + return err + } + return out.Write(result) +} + +// AgentsGrantsDetachSecretCmd is `onecli agents grants detach-secret`. +type AgentsGrantsDetachSecretCmd struct { + ID string `required:"" help:"ID of the agent."` + SecretID string `required:"" name:"secret-id" help:"ID of the secret to detach."` + Project string `optional:"" short:"p" help:"Project slug."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AgentsGrantsDetachSecretCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid agent ID: %w", err) + } + if err := validate.ResourceID(c.SecretID); err != nil { + return fmt.Errorf("invalid secret ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would detach the secret from the agent", map[string]any{ + "id": c.ID, "secret_id": c.SecretID, + }) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + if err := client.RemoveAgentSecretGrant(newContext(), project, c.ID, c.SecretID); err != nil { + return err + } + return out.Write(map[string]string{ + "status": "detached", "agentId": c.ID, "secretId": c.SecretID, + }) +} + +// AppsConnectionsGrantsCmd is `onecli apps connections grants` — which agents +// a connection is granted to (attach intent; 'agent-access' is the effective +// view). +type AppsConnectionsGrantsCmd struct { + ID string `required:"" help:"ID of the app connection."` + Project string `optional:"" short:"p" help:"Project slug."` + Fields string `optional:"" help:"Comma-separated fields to include."` + Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line."` +} + +func (c *AppsConnectionsGrantsCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid connection ID: %w", err) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + result, err := client.GetConnectionGrants(newContext(), project, c.ID) + if err != nil { + return err + } + if c.Quiet != "" { + return out.WriteQuiet(result, c.Quiet) + } + return out.WriteFiltered(result, c.Fields) +} + +// buildConnectionGrantInput derives the grant body from the flags: no flags = +// the uncustomized full attach; --allow/--ask derive a custom grant; --json +// takes the raw wire body and cannot combine with the flags. Tool IDs are +// validated for syntax only — catalog membership is the server's call. +func buildConnectionGrantInput(jsonRaw, allowCSV, askCSV string) (api.ConnectionGrantInput, error) { + if jsonRaw != "" { + if allowCSV != "" || askCSV != "" { + return api.ConnectionGrantInput{}, fmt.Errorf("--json carries the whole grant body; do not combine it with --allow/--ask") + } + var raw struct { + Access string `json:"access"` + Allow []string `json:"allow"` + Ask []string `json:"ask"` + } + if err := json.Unmarshal([]byte(jsonRaw), &raw); err != nil { + return api.ConnectionGrantInput{}, fmt.Errorf("invalid --json: %w", err) + } + if raw.Access != "full" && raw.Access != "custom" { + return api.ConnectionGrantInput{}, fmt.Errorf("invalid --json: access must be \"full\" or \"custom\"") + } + return api.ConnectionGrantInput{Access: raw.Access, Allow: raw.Allow, Ask: raw.Ask}, nil + } + allow := splitCSV(allowCSV) + ask := splitCSV(askCSV) + if len(allow) == 0 && len(ask) == 0 { + return api.ConnectionGrantInput{Access: "full"}, nil + } + for _, tool := range append(append([]string{}, allow...), ask...) { + if err := validate.ResourceID(tool); err != nil { + return api.ConnectionGrantInput{}, fmt.Errorf("invalid tool ID %q: %w", tool, err) + } + } + return api.ConnectionGrantInput{Access: "custom", Allow: allow, Ask: ask}, nil +} diff --git a/cmd/onecli/grants_test.go b/cmd/onecli/grants_test.go new file mode 100644 index 0000000..27c3b78 --- /dev/null +++ b/cmd/onecli/grants_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "strings" + "testing" +) + +// The flag → wire-body derivation: no flags = the uncustomized full attach; +// --allow/--ask derive a custom grant; --json takes the raw body and cannot +// combine with the flags. + +func TestBuildConnectionGrantInput(t *testing.T) { + t.Run("no flags derives the full attach", func(t *testing.T) { + input, err := buildConnectionGrantInput("", "", "") + if err != nil { + t.Fatal(err) + } + if input.Access != "full" || input.Allow != nil || input.Ask != nil { + t.Errorf("got %+v, want the bare full grant", input) + } + }) + + t.Run("allow alone derives custom", func(t *testing.T) { + input, err := buildConnectionGrantInput("", "search_messages, get_message", "") + if err != nil { + t.Fatal(err) + } + if input.Access != "custom" { + t.Errorf("access = %q, want custom", input.Access) + } + if len(input.Allow) != 2 || input.Allow[1] != "get_message" { + t.Errorf("allow = %v (CSV must trim)", input.Allow) + } + if len(input.Ask) != 0 { + t.Errorf("ask = %v, want empty", input.Ask) + } + }) + + t.Run("ask alone derives custom", func(t *testing.T) { + input, err := buildConnectionGrantInput("", "", "send_email") + if err != nil { + t.Fatal(err) + } + if input.Access != "custom" || len(input.Ask) != 1 { + t.Errorf("got %+v", input) + } + }) + + t.Run("both lists carry through", func(t *testing.T) { + input, err := buildConnectionGrantInput("", "get_message", "send_email") + if err != nil { + t.Fatal(err) + } + if len(input.Allow) != 1 || len(input.Ask) != 1 { + t.Errorf("got %+v", input) + } + }) + + t.Run("json is taken verbatim", func(t *testing.T) { + input, err := buildConnectionGrantInput(`{"access":"custom","allow":["a_tool"],"ask":[]}`, "", "") + if err != nil { + t.Fatal(err) + } + if input.Access != "custom" || len(input.Allow) != 1 { + t.Errorf("got %+v", input) + } + }) + + t.Run("json rejects combining with flags", func(t *testing.T) { + _, err := buildConnectionGrantInput(`{"access":"full"}`, "a_tool", "") + if err == nil || !strings.Contains(err.Error(), "do not combine") { + t.Errorf("err = %v, want the do-not-combine rejection", err) + } + }) + + t.Run("json rejects an unknown access", func(t *testing.T) { + _, err := buildConnectionGrantInput(`{"access":"partial"}`, "", "") + if err == nil || !strings.Contains(err.Error(), "full") { + t.Errorf("err = %v, want the access rejection", err) + } + }) + + t.Run("an invalid tool id is rejected client-side", func(t *testing.T) { + _, err := buildConnectionGrantInput("", "ok_tool,bad tool!", "") + if err == nil || !strings.Contains(err.Error(), "invalid tool ID") { + t.Errorf("err = %v, want the tool-id rejection", err) + } + }) +} diff --git a/cmd/onecli/help.go b/cmd/onecli/help.go index aa59a72..b1561ae 100644 --- a/cmd/onecli/help.go +++ b/cmd/onecli/help.go @@ -48,6 +48,7 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--dry-run", Description: "Print resolved env and command without executing."}, }}, {Name: "agents list", Description: "List all agents.", Args: []ArgInfo{ + {Name: "--with-grants", Description: "Include each agent's grants summary (attached connections, secrets, LLM keys)."}, {Name: "--project, -p", Description: "Project slug."}, }}, {Name: "agents get-default", Description: "Get the default agent."}, @@ -66,7 +67,7 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "agents regenerate-token", Description: "Regenerate an agent's access token.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the agent."}, }}, - {Name: "agents secrets", Description: "RETIRED — updated servers answer 410 Gone. Use 'agents credentials' — it reports what the policy actually grants.", Args: []ArgInfo{ + {Name: "agents secrets", Description: "RETIRED — updated servers answer 410 Gone. Use 'agents grants list' (attached) or 'agents credentials' (effective).", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the agent."}, }}, {Name: "agents credentials", Description: "Show which credentials the agent can use and what each one can do (read-only reflection of the published policy).", Args: []ArgInfo{ @@ -75,7 +76,40 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--fields", Description: "Comma-separated fields to include."}, {Name: "--quiet", Description: "Output only the specified field, one per line."}, }}, - {Name: "agents set-secrets", Description: "RETIRED — updated servers answer 410 Gone. Grant a secret with an allow rule naming the agent ('policy rules create').", Args: []ArgInfo{ + {Name: "agents grants list", Description: "Show the agent's grants: attached app connections (with per-tool access) and secrets. Grants are attach INTENT; 'agents credentials' is the effective view.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the agent."}, + {Name: "--project, -p", Description: "Project slug."}, + {Name: "--fields", Description: "Comma-separated fields to include."}, + {Name: "--quiet", Description: "Output only the specified field, one per line."}, + }}, + {Name: "agents grants attach-connection", Description: "Attach an app connection to the agent. No tool flags = full access; --allow/--ask set per-tool access (the rest is blocked).", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the agent."}, + {Name: "--connection-id", Required: true, Description: "ID of the app connection to attach."}, + {Name: "--allow", Description: "Comma-separated tool IDs to always allow (from 'apps permission-definition')."}, + {Name: "--ask", Description: "Comma-separated tool IDs that require manual approval before running."}, + {Name: "--json", Description: "Raw JSON grant body (do not combine with --allow/--ask)."}, + {Name: "--project, -p", Description: "Project slug."}, + {Name: "--dry-run", Description: "Validate the request without executing it."}, + }}, + {Name: "agents grants detach-connection", Description: "Detach an app connection from the agent.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the agent."}, + {Name: "--connection-id", Required: true, Description: "ID of the app connection to detach."}, + {Name: "--project, -p", Description: "Project slug."}, + {Name: "--dry-run", Description: "Validate the request without executing it."}, + }}, + {Name: "agents grants attach-secret", Description: "Attach a secret or LLM key to the agent.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the agent."}, + {Name: "--secret-id", Required: true, Description: "ID of the secret (or LLM key) to attach."}, + {Name: "--project, -p", Description: "Project slug."}, + {Name: "--dry-run", Description: "Validate the request without executing it."}, + }}, + {Name: "agents grants detach-secret", Description: "Detach a secret from the agent.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the agent."}, + {Name: "--secret-id", Required: true, Description: "ID of the secret to detach."}, + {Name: "--project, -p", Description: "Project slug."}, + {Name: "--dry-run", Description: "Validate the request without executing it."}, + }}, + {Name: "agents set-secrets", Description: "RETIRED — updated servers answer 410 Gone. Attach with 'agents grants attach-secret'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the agent."}, {Name: "--secret-ids", Required: true, Description: "Comma-separated list of secret IDs."}, }}, @@ -83,14 +117,14 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--id", Required: true, Description: "ID of the agent."}, }}, {Name: "agents granular-access", Description: "RETIRED — updated servers answer 410 Gone. Resource scoping rides the granting rule's conditions; see 'agents credentials'."}, - {Name: "agents connections get", Description: "RETIRED — updated servers answer 410 Gone. Use 'agents credentials'.", Args: []ArgInfo{ + {Name: "agents connections get", Description: "RETIRED — updated servers answer 410 Gone. Use 'agents grants list'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the agent."}, }}, - {Name: "agents connections set", Description: "RETIRED — updated servers answer 410 Gone. Grant a connection with an allow rule naming the agent ('policy rules create').", Args: []ArgInfo{ + {Name: "agents connections set", Description: "RETIRED — updated servers answer 410 Gone. Attach with 'agents grants attach-connection'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the agent."}, {Name: "--json", Required: true, Description: "JSON array of connection assignments."}, }}, - {Name: "agents set-secret-mode", Description: "Set an agent's secret mode.", Args: []ArgInfo{ + {Name: "agents set-secret-mode", Description: "RETIRED — updated servers answer 410 Gone; agents are always selective. Attach credentials with 'agents grants'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the agent."}, {Name: "--mode", Required: true, Description: "Secret mode: 'all' or 'selective'."}, }}, @@ -140,6 +174,12 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--fields", Description: "Comma-separated fields to include."}, {Name: "--quiet", Description: "Output only the specified field, one per line."}, }}, + {Name: "apps connections grants", Description: "Show which agents a connection is granted to (attach intent, read-only; 'agent-access' is the effective view).", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the app connection."}, + {Name: "--project, -p", Description: "Project slug."}, + {Name: "--fields", Description: "Comma-separated fields to include."}, + {Name: "--quiet", Description: "Output only the specified field, one per line."}, + }}, {Name: "apps permission-definition", Description: "Show an app's tool catalog (groups + toolIds) for permission rules.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, }}, @@ -173,27 +213,30 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--provider", Required: true, Description: "Provider name."}, {Name: "--rule-id", Required: true, Description: "Blocklist rule ID."}, }}, - {Name: "rules list", Description: "RETIRED — updated servers answer 410 Gone. Use 'policy rules list'.", Args: []ArgInfo{ + {Name: "rules list", Description: "RETIRED — updated servers answer 410 Gone. Grant access with 'agents grants'; read it with 'agents credentials'.", Args: []ArgInfo{ {Name: "--project, -p", Description: "Project slug."}, }}, - {Name: "rules create", Description: "RETIRED — updated servers answer 410 Gone. Use 'policy rules create'.", Args: []ArgInfo{ + {Name: "rules get", Description: "RETIRED — updated servers answer 410 Gone. Grant access with 'agents grants'.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the rule."}, + }}, + {Name: "rules create", Description: "RETIRED — updated servers answer 410 Gone. Attach credentials with 'agents grants attach-connection' / 'attach-secret'.", Args: []ArgInfo{ {Name: "--project, -p", Description: "Project slug."}, {Name: "--name", Required: true, Description: "Display name for the rule."}, {Name: "--host-pattern", Required: true, Description: "Host pattern to match."}, {Name: "--action", Required: true, Description: "Action: 'block', 'rate_limit', 'manual_approval', or 'allow'."}, {Name: "--conditions", Description: "Content conditions as a JSON array."}, }}, - {Name: "rules update", Description: "RETIRED — updated servers answer 410 Gone. Use 'policy rules update'.", Args: []ArgInfo{ + {Name: "rules update", Description: "RETIRED — updated servers answer 410 Gone. Manage per-tool access with 'agents grants attach-connection --allow/--ask'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to update."}, }}, - {Name: "rules delete", Description: "RETIRED — updated servers answer 410 Gone. Use 'policy rules delete'.", Args: []ArgInfo{ + {Name: "rules delete", Description: "RETIRED — updated servers answer 410 Gone. Detach with 'agents grants detach-connection' / 'detach-secret'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to delete."}, }}, - {Name: "rules permissions get", Description: "RETIRED — updated servers answer 410 Gone. Use 'policy effective-permissions' to read what the published policy allows.", Args: []ArgInfo{ + {Name: "rules permissions get", Description: "RETIRED — updated servers answer 410 Gone. Use 'policy effective-permissions' to read; set per-tool access with 'agents grants'.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, {Name: "--agent-id", Description: "Show only this agent's override layer."}, }}, - {Name: "rules permissions set", Description: "RETIRED — updated servers answer 410 Gone. Author an app-target rule with 'policy rules create' instead.", Args: []ArgInfo{ + {Name: "rules permissions set", Description: "RETIRED — updated servers answer 410 Gone. Set per-tool access with 'agents grants attach-connection --allow/--ask'.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, {Name: "--tool", Description: "Tool ID (see 'apps permission-definition')."}, {Name: "--permission", Description: "Permission: 'allow', 'manual_approval', 'block', or 'inherit' (agent layer only)."}, @@ -203,14 +246,14 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "rules overlap", Description: "RETIRED — updated servers answer 410 Gone. No replacement — overlap detection lives in the Policy console.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name."}, }}, - {Name: "policy rules list", Description: "List policy-engine rules (draft or the enforced published set).", Args: []ArgInfo{ + {Name: "policy rules list", Description: "RETIRED at project scope — updated servers answer 410 Gone (project rules are compiled from grants). Use 'agents grants list'; org rules: 'org policy rules list'.", Args: []ArgInfo{ {Name: "--project, -p", Description: "Project slug."}, {Name: "--status", Description: "'draft' (default) or 'published' (enforced)."}, }}, - {Name: "policy rules get", Description: "Get one DRAFT policy rule by id.", Args: []ArgInfo{ + {Name: "policy rules get", Description: "RETIRED at project scope — updated servers answer 410 Gone. Use 'agents grants list'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "Draft rule id (published ids regenerate every publish — match by logicalId)."}, }}, - {Name: "policy rules create", Description: "Create a policy rule (auto-publishes when the draft is otherwise clean).", Args: []ArgInfo{ + {Name: "policy rules create", Description: "RETIRED at project scope — updated servers answer 410 Gone. Attach credentials with 'agents grants'; org rules: 'org policy rules create'.", Args: []ArgInfo{ {Name: "--name", Description: "Display name (required unless --json)."}, {Name: "--action", Description: "'allow' or 'block' (required unless --json)."}, {Name: "--targets", Description: "JSON array of targets: app/connection/secret/network (required unless --json)."}, @@ -221,23 +264,23 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--no-publish", Description: "Stage only."}, {Name: "--publish-all", Description: "Publish even when the draft holds other staged changes."}, }}, - {Name: "policy rules update", Description: "Update a DRAFT policy rule (same publish flags as create).", Args: []ArgInfo{ + {Name: "policy rules update", Description: "RETIRED at project scope — updated servers answer 410 Gone. Manage per-tool access with 'agents grants attach-connection --allow/--ask'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "Draft rule id."}, }}, - {Name: "policy rules delete", Description: "Delete a DRAFT policy rule (same publish flags).", Args: []ArgInfo{ + {Name: "policy rules delete", Description: "RETIRED at project scope — updated servers answer 410 Gone. Detach with 'agents grants detach-connection' / 'detach-secret'.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "Draft rule id."}, }}, - {Name: "policy rules reorder", Description: "Reorder the draft — the id list must name EVERY non-default draft rule.", Args: []ArgInfo{ + {Name: "policy rules reorder", Description: "RETIRED at project scope — updated servers answer 410 Gone. Grants have no ordering; org rules: 'org policy rules reorder'.", Args: []ArgInfo{ {Name: "--ordered-ids", Required: true, Description: "JSON array of all draft rule ids (from 'policy rules list --quiet id')."}, }}, - {Name: "policy default get", Description: "Show the terminal Default Rule.", Args: []ArgInfo{ + {Name: "policy default get", Description: "RETIRED at project scope — updated servers answer 410 Gone. The posture is the org Default Rule: 'org policy default get'.", Args: []ArgInfo{ {Name: "--status", Description: "'draft' (default) or 'published'."}, }}, - {Name: "policy default set", Description: "Set the Default Rule's action.", Args: []ArgInfo{ + {Name: "policy default set", Description: "RETIRED at project scope — updated servers answer 410 Gone. Set the posture with 'org policy default set --action'.", Args: []ArgInfo{ {Name: "--action", Required: true, Description: "'allow' or 'block'."}, }}, - {Name: "policy publish", Description: "Publish the WHOLE staged draft (all staged changes, yours and others')."}, - {Name: "policy status", Description: "Show staged changes (the diff) and the last publish."}, + {Name: "policy publish", Description: "RETIRED at project scope — updated servers answer 410 Gone. Grant changes publish immediately; org drafts: 'org policy publish'."}, + {Name: "policy status", Description: "RETIRED at project scope — updated servers answer 410 Gone. Use 'agents grants list' and 'org policy status'."}, {Name: "projects list", Description: "List all projects."}, {Name: "projects get", Description: "Get a single project by ID.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the project to retrieve."}, @@ -393,7 +436,7 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--rule-id", Required: true, Description: "Blocklist rule ID."}, }}, {Name: "org settings get", Description: "RETIRED — updated servers answer 410 Gone. The allow/deny posture is the Default Rule — use 'org policy default'."}, - {Name: "org settings set", Description: "RETIRED — updated servers answer 410 Gone. Set the posture with 'org policy default --action'.", Args: []ArgInfo{ + {Name: "org settings set", Description: "RETIRED — updated servers answer 410 Gone. Set the posture with 'org policy default set --action'.", Args: []ArgInfo{ {Name: "--policy-mode", Required: true, Description: "Policy mode: 'allow' or 'deny'."}, }}, {Name: "vaults list", Description: "List external vault connections (e.g. 1Password)."}, diff --git a/cmd/onecli/help_test.go b/cmd/onecli/help_test.go new file mode 100644 index 0000000..85285cb --- /dev/null +++ b/cmd/onecli/help_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/alecthomas/kong" + + "github.com/onecli/onecli-cli/pkg/output" +) + +// help.go's command list is hand-maintained while subcommand --help walks the +// live Kong tree — two independent sources of truth that have historically +// drifted (commands retired in one and neutral in the other). This pins them +// together in both directions, plus RETIRED-marker parity. + +// helpListEntries runs the hand-maintained `onecli help` and returns its +// entries, with positional suffixes trimmed ("config get " → "config get"). +func helpListEntries(t *testing.T) map[string]string { + t.Helper() + var buf bytes.Buffer + out := output.NewWithWriters(&buf, &bytes.Buffer{}) + if err := (&HelpCmd{}).Run(out); err != nil { + t.Fatalf("running help: %v", err) + } + var resp HelpResponse + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("help output is not JSON: %v", err) + } + entries := make(map[string]string, len(resp.Commands)) + for _, cmd := range resp.Commands { + name := cmd.Name + if i := strings.Index(name, " <"); i > 0 { + name = name[:i] + } + entries[name] = cmd.Description + } + return entries +} + +// kongLeafEntries builds the live Kong tree and returns every visible leaf +// command with its help string. +func kongLeafEntries(t *testing.T) map[string]string { + t.Helper() + k, err := kong.New(&CLI{}, kong.Name("onecli")) + if err != nil { + t.Fatalf("building the kong tree: %v", err) + } + var commands []CommandInfo + for _, node := range k.Model.Children { + collectKongLeafCommands(node, "", &commands) + } + entries := make(map[string]string, len(commands)) + for _, cmd := range commands { + entries[cmd.Name] = cmd.Description + } + return entries +} + +// Commands that appear in exactly one surface on purpose. +var helpDriftAllowlist = map[string]bool{ + // The help meta-command deliberately doesn't list itself. + "help": true, +} + +func TestHelpListMatchesTheKongTree(t *testing.T) { + helpEntries := helpListEntries(t) + kongEntries := kongLeafEntries(t) + + for name := range kongEntries { + if helpDriftAllowlist[name] { + continue + } + if _, ok := helpEntries[name]; !ok { + t.Errorf("kong command %q is missing from the hand-maintained help list (help.go)", name) + } + } + for name := range helpEntries { + if helpDriftAllowlist[name] { + continue + } + if _, ok := kongEntries[name]; !ok { + t.Errorf("help.go lists %q, which is not a command in the kong tree", name) + } + } +} + +func TestRetiredMarkersAgreeAcrossHelpSurfaces(t *testing.T) { + helpEntries := helpListEntries(t) + kongEntries := kongLeafEntries(t) + + for name, kongDesc := range kongEntries { + helpDesc, ok := helpEntries[name] + if !ok || helpDriftAllowlist[name] { + continue // the presence test above owns missing entries + } + kongRetired := strings.HasPrefix(kongDesc, "RETIRED") + helpRetired := strings.HasPrefix(helpDesc, "RETIRED") + if kongRetired != helpRetired { + t.Errorf( + "%q disagrees on retirement: kong help says retired=%v, help.go says retired=%v", + name, kongRetired, helpRetired, + ) + } + } +} diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index 013f790..39276f0 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -121,7 +121,7 @@ func handleError(out *output.Writer, err error) { _ = out.ErrorWithAction( exitcode.CodeGone, apiErr.Message, - "onecli policy --help (org rules: 'onecli org policy --help')", + "project access: 'onecli agents grants --help' — org rules: 'onecli org policy --help'", ) os.Exit(exitcode.Error) case 422: diff --git a/cmd/onecli/org.go b/cmd/onecli/org.go index e65c441..5526d7f 100644 --- a/cmd/onecli/org.go +++ b/cmd/onecli/org.go @@ -7,5 +7,5 @@ type OrgCmd struct { Policy OrgPolicyCmd `cmd:"" help:"Manage org policy rules on the policy engine (draft → publish)."` Connections OrgConnectionsCmd `cmd:"" help:"Manage org-scoped connections."` Apps OrgAppsCmd `cmd:"" help:"Manage org-scoped app configuration."` - Settings OrgSettingsCmd `cmd:"" help:"Manage organization settings (policy mode)."` + Settings OrgSettingsCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. The allow/deny posture is the org Default Rule ('org policy default')."` } diff --git a/cmd/onecli/org_rules.go b/cmd/onecli/org_rules.go index 7b80213..8aff5b5 100644 --- a/cmd/onecli/org_rules.go +++ b/cmd/onecli/org_rules.go @@ -11,13 +11,13 @@ import ( // OrgRulesCmd is the `onecli org rules` command group. type OrgRulesCmd struct { - List OrgRulesListCmd `cmd:"" help:"List all org-scoped policy rules."` - Get OrgRulesGetCmd `cmd:"" help:"Get a single org-scoped policy rule by ID."` - Create OrgRulesCreateCmd `cmd:"" help:"Create a new org-scoped policy rule."` - Update OrgRulesUpdateCmd `cmd:"" help:"Update an org-scoped policy rule."` - Delete OrgRulesDeleteCmd `cmd:"" help:"Delete an org-scoped policy rule."` - Permissions OrgRulesPermissionsCmd `cmd:"" help:"Manage app-level tool permissions."` - Overlap OrgRulesOverlapCmd `cmd:"" help:"Count custom org rules overlapping an app's hosts."` + List OrgRulesListCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'org policy rules list'."` + Get OrgRulesGetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'org policy rules get'."` + Create OrgRulesCreateCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'org policy rules create'."` + Update OrgRulesUpdateCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'org policy rules update'."` + Delete OrgRulesDeleteCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'org policy rules delete'."` + Permissions OrgRulesPermissionsCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'org policy effective-permissions' to read; author app-target rules with 'org policy rules create'."` + Overlap OrgRulesOverlapCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. No replacement — overlap detection lives in the Policy console."` } // OrgRulesOverlapCmd is `onecli org rules overlap`. @@ -264,8 +264,8 @@ func (c *OrgRulesDeleteCmd) Run(out *output.Writer) error { // OrgRulesPermissionsCmd is `onecli org rules permissions`. type OrgRulesPermissionsCmd struct { - Get OrgRulesPermissionsGetCmd `cmd:"" help:"Get tool permissions for a provider."` - Set OrgRulesPermissionsSetCmd `cmd:"" help:"Set tool permissions for a provider."` + Get OrgRulesPermissionsGetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'org policy effective-permissions'."` + Set OrgRulesPermissionsSetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Author an app-target rule with 'org policy rules create' instead."` } // OrgRulesPermissionsGetCmd is `onecli org rules permissions get`. diff --git a/cmd/onecli/policy.go b/cmd/onecli/policy.go index e23e2db..9d1b34e 100644 --- a/cmd/onecli/policy.go +++ b/cmd/onecli/policy.go @@ -20,21 +20,21 @@ import ( // PolicyCmd is `onecli policy` (project scope). type PolicyCmd struct { - Rules PolicyRulesCmd `cmd:"" help:"Manage the project's policy rules (draft → publish)."` - Default PolicyDefaultCmd `cmd:"" help:"Show or set the project's terminal Default Rule."` - Publish PolicyPublishCmd `cmd:"" help:"Publish the project's staged draft (all staged changes)."` - Status PolicyStatusCmd `cmd:"" help:"Show staged changes and the last publish."` + Rules PolicyRulesCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone (project rules are compiled from grants). Attach credentials with 'agents grants'; org rules live under 'org policy'."` + Default PolicyDefaultCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. The org Default Rule ('org policy default') is the posture."` + Publish PolicyPublishCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. Grant changes publish immediately; org drafts publish with 'org policy publish'."` + Status PolicyStatusCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. Use 'agents grants list' and 'org policy status'."` EffectivePermissions PolicyEffectivePermissionsCmd `cmd:"" name:"effective-permissions" help:"Show what the published policy allows for an app, per tool (read-only)."` } // PolicyRulesCmd groups the rule subcommands. type PolicyRulesCmd struct { - List PolicyRulesListCmd `cmd:"" help:"List policy rules (draft by default; --status published = the enforced set)."` - Get PolicyRulesGetCmd `cmd:"" help:"Get one DRAFT rule by id."` - Create PolicyRulesCreateCmd `cmd:"" help:"Create a policy rule."` - Update PolicyRulesUpdateCmd `cmd:"" help:"Update a DRAFT policy rule."` - Delete PolicyRulesDeleteCmd `cmd:"" help:"Delete a DRAFT policy rule."` - Reorder PolicyRulesReorderCmd `cmd:"" help:"Reorder the draft (full id permutation)."` + List PolicyRulesListCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone (project rules are compiled from grants). Use 'agents grants list'; org rules: 'org policy rules list'."` + Get PolicyRulesGetCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. Use 'agents grants list'."` + Create PolicyRulesCreateCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. Attach credentials with 'agents grants'; org rules: 'org policy rules create'."` + Update PolicyRulesUpdateCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. Manage per-tool access with 'agents grants attach-connection --allow/--ask'."` + Delete PolicyRulesDeleteCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. Detach with 'agents grants detach-connection' / 'detach-secret'."` + Reorder PolicyRulesReorderCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. Grants have no ordering; org rules: 'org policy rules reorder'."` } // ── Shared write/publish plumbing (used by the org variants too) ──────────── @@ -198,7 +198,7 @@ func requireProjectForOrgKey(project string) error { } if strings.HasPrefix(loadStoredAPIKey(), "oc_org_") { return fmt.Errorf( - "an organization API key needs an explicit project for 'onecli policy' — pass --project , set ONECLI_PROJECT, or run 'onecli config set project '", + "an organization API key needs an explicit project for this project-scoped command — pass --project , set ONECLI_PROJECT, or run 'onecli config set project '", ) } return nil @@ -671,8 +671,8 @@ func (c *PolicyRulesReorderCmd) Run(out *output.Writer) error { // PolicyDefaultCmd groups the Default Rule subcommands. type PolicyDefaultCmd struct { - Get PolicyDefaultGetCmd `cmd:"" help:"Show the terminal Default Rule."` - Set PolicyDefaultSetCmd `cmd:"" help:"Set the Default Rule's action."` + Get PolicyDefaultGetCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. The posture is the org Default Rule: 'org policy default get'."` + Set PolicyDefaultSetCmd `cmd:"" help:"RETIRED at project scope — updated servers answer 410 Gone. Set the posture with 'org policy default set --action'."` } // PolicyDefaultGetCmd is `onecli policy default get`. diff --git a/cmd/onecli/resources.go b/cmd/onecli/resources.go index 9ce3fcd..a301eff 100644 --- a/cmd/onecli/resources.go +++ b/cmd/onecli/resources.go @@ -52,8 +52,8 @@ func (c *CountsCmd) Run(out *output.Writer) error { // OrgSettingsCmd is the `onecli org settings` command group. type OrgSettingsCmd struct { - Get OrgSettingsGetCmd `cmd:"" help:"Get organization settings (policy mode)."` - Set OrgSettingsSetCmd `cmd:"" help:"Update organization settings."` + Get OrgSettingsGetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. The allow/deny posture is the Default Rule — use 'org policy default'."` + Set OrgSettingsSetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Set the posture with 'org policy default set --action'."` } // OrgSettingsGetCmd is `onecli org settings get`. diff --git a/cmd/onecli/rules.go b/cmd/onecli/rules.go index efd3f87..dc537f4 100644 --- a/cmd/onecli/rules.go +++ b/cmd/onecli/rules.go @@ -11,19 +11,19 @@ import ( // RulesCmd is the `onecli rules` command group. type RulesCmd struct { - List RulesListCmd `cmd:"" help:"List all policy rules."` - Get RulesGetCmd `cmd:"" help:"Get a single policy rule by ID."` - Create RulesCreateCmd `cmd:"" help:"Create a new policy rule."` - Update RulesUpdateCmd `cmd:"" help:"Update an existing policy rule."` - Delete RulesDeleteCmd `cmd:"" help:"Delete a policy rule."` - Permissions RulesPermissionsCmd `cmd:"" help:"Manage app-level tool permissions (supports per-agent overrides)."` - Overlap RulesOverlapCmd `cmd:"" help:"Count custom rules overlapping an app's hosts."` + List RulesListCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Grant access with 'agents grants'; read it with 'agents credentials'."` + Get RulesGetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Grant access with 'agents grants'."` + Create RulesCreateCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Attach credentials with 'agents grants attach-connection' / 'attach-secret'."` + Update RulesUpdateCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Manage per-tool access with 'agents grants attach-connection --allow/--ask'."` + Delete RulesDeleteCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Detach with 'agents grants detach-connection' / 'detach-secret'."` + Permissions RulesPermissionsCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Read the catalog with 'apps permission-definition'; set per-tool access with 'agents grants'."` + Overlap RulesOverlapCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. No replacement — overlap detection lives in the organization Policy console."` } // RulesPermissionsCmd is `onecli rules permissions`. type RulesPermissionsCmd struct { - Get RulesPermissionsGetCmd `cmd:"" help:"Get layered tool permissions for a provider."` - Set RulesPermissionsSetCmd `cmd:"" help:"Set tool permissions for a provider (optionally for one agent)."` + Get RulesPermissionsGetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Use 'policy effective-permissions' to read; set per-tool access with 'agents grants'."` + Set RulesPermissionsSetCmd `cmd:"" help:"RETIRED — updated servers answer 410 Gone. Set per-tool access with 'agents grants attach-connection --allow/--ask'."` } // RulesPermissionsGetCmd is `onecli rules permissions get`. diff --git a/internal/api/grants.go b/internal/api/grants.go new file mode 100644 index 0000000..ed402b3 --- /dev/null +++ b/internal/api/grants.go @@ -0,0 +1,194 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// The attach-model grants surface: a grant attaches one credential (an app +// connection, secret, or LLM key) to one agent, and is the only project-scope +// policy writer — the server compiles grants into policy rules. Grants are +// the attach INTENT; the effective view (org guardrails applied) stays with +// 'agents credentials' / 'apps connections agent-access'. + +// AgentGrantConnection is one attached app connection with its per-tool access. +type AgentGrantConnection struct { + ConnectionID string `json:"connectionId"` + Provider string `json:"provider"` + Label *string `json:"label"` + Scope string `json:"scope"` // "project" | "organization" + Access string `json:"access"` // "full" | "custom" + Allow []string `json:"allow"` + Ask []string `json:"ask"` +} + +// AgentGrantSecret is one attached secret or LLM key. +type AgentGrantSecret struct { + SecretID string `json:"secretId"` + Name string `json:"name"` + Type string `json:"type"` + Scope string `json:"scope"` +} + +// AgentGrants is an agent's full grant set — returned by reads AND mutations +// (the server answers every grant write with the agent's fresh state). +type AgentGrants struct { + AgentID string `json:"agentId"` + Mode string `json:"mode"` // "grants" on current servers; "all" only from older ones + Connections []AgentGrantConnection `json:"connections"` + Secrets []AgentGrantSecret `json:"secrets"` +} + +// ConnectionGrantAgent is one agent granted a connection (the reverse view). +type ConnectionGrantAgent struct { + AgentID string `json:"agentId"` + Access string `json:"access"` + Allow []string `json:"allow"` + Ask []string `json:"ask"` +} + +// ConnectionGrants lists which agents a connection is granted to. +type ConnectionGrants struct { + ConnectionID string `json:"connectionId"` + Agents []ConnectionGrantAgent `json:"agents"` +} + +// ConnectionGrantInput describes the desired connection grant. +// Access "full" ignores the tool lists; "custom" uses them (each tool is +// always-allowed via Allow or approval-gated via Ask; the rest is blocked). +type ConnectionGrantInput struct { + Access string + Allow []string + Ask []string +} + +// fullGrantBody is the uncustomized attach: exactly {"access":"full"}. +type fullGrantBody struct { + Access string `json:"access"` +} + +// customGrantBody must carry BOTH arrays — the server's schema requires the +// keys, and rejects null where it expects a list, so empties stay []. +type customGrantBody struct { + Access string `json:"access"` + Allow []string `json:"allow"` + Ask []string `json:"ask"` +} + +// GrantsSummaryEntry is one attached credential in the summary projection. +// Kind selects the arms: "app" carries Provider/ConnectionID/Label; +// "secret" and "llm" carry ID/Name. +type GrantsSummaryEntry struct { + Kind string `json:"kind"` + Provider string `json:"provider,omitempty"` + ConnectionID string `json:"connectionId,omitempty"` + Label *string `json:"label,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// AgentGrantsSummary is the attach-list summary for one agent. +type AgentGrantsSummary struct { + Mode string `json:"mode"` + Entries []GrantsSummaryEntry `json:"entries"` + Total int `json:"total"` +} + +// AgentWithGrantsSummary is an agent plus its grants summary +// (GET /v1/agents?include=grants-summary). +type AgentWithGrantsSummary struct { + Agent + GrantsSummary AgentGrantsSummary `json:"grantsSummary"` +} + +// GetAgentGrants returns the agent's grants: attached connections (with +// per-tool access) and secrets. +func (c *Client) GetAgentGrants(ctx context.Context, project, agentID string) (*AgentGrants, error) { + path := "/v1/agents/" + url.PathEscape(agentID) + "/grants" + var result AgentGrants + if err := c.doProject(ctx, http.MethodGet, path, project, nil, &result); err != nil { + return nil, fmt.Errorf("getting agent grants: %w", err) + } + return &result, nil +} + +// SetAgentConnectionGrant attaches an app connection to the agent (or replaces +// its per-tool access). Returns the agent's fresh grant set. +func (c *Client) SetAgentConnectionGrant(ctx context.Context, project, agentID, connectionID string, input ConnectionGrantInput) (*AgentGrants, error) { + path := "/v1/agents/" + url.PathEscape(agentID) + "/grants/connections/" + url.PathEscape(connectionID) + var result AgentGrants + if err := c.doProject(ctx, http.MethodPut, path, project, normalizeGrantBody(input), &result); err != nil { + return nil, fmt.Errorf("attaching connection: %w", err) + } + return &result, nil +} + +// RemoveAgentConnectionGrant detaches an app connection from the agent. +// The server answers 204 with no body. +func (c *Client) RemoveAgentConnectionGrant(ctx context.Context, project, agentID, connectionID string) error { + path := "/v1/agents/" + url.PathEscape(agentID) + "/grants/connections/" + url.PathEscape(connectionID) + if err := c.doProject(ctx, http.MethodDelete, path, project, nil, nil); err != nil { + return fmt.Errorf("detaching connection: %w", err) + } + return nil +} + +// SetAgentSecretGrant attaches a secret (or LLM key) to the agent. The PUT +// takes no body — a secret grant is attach-only, with no per-tool axis. +func (c *Client) SetAgentSecretGrant(ctx context.Context, project, agentID, secretID string) (*AgentGrants, error) { + path := "/v1/agents/" + url.PathEscape(agentID) + "/grants/secrets/" + url.PathEscape(secretID) + var result AgentGrants + if err := c.doProject(ctx, http.MethodPut, path, project, nil, &result); err != nil { + return nil, fmt.Errorf("attaching secret: %w", err) + } + return &result, nil +} + +// RemoveAgentSecretGrant detaches a secret from the agent (204, no body). +func (c *Client) RemoveAgentSecretGrant(ctx context.Context, project, agentID, secretID string) error { + path := "/v1/agents/" + url.PathEscape(agentID) + "/grants/secrets/" + url.PathEscape(secretID) + if err := c.doProject(ctx, http.MethodDelete, path, project, nil, nil); err != nil { + return fmt.Errorf("detaching secret: %w", err) + } + return nil +} + +// GetConnectionGrants returns which agents a connection is granted to (the +// attach intent; 'apps connections agent-access' is the effective view). +func (c *Client) GetConnectionGrants(ctx context.Context, project, connectionID string) (*ConnectionGrants, error) { + path := "/v1/connections/" + url.PathEscape(connectionID) + "/grants" + var result ConnectionGrants + if err := c.doProject(ctx, http.MethodGet, path, project, nil, &result); err != nil { + return nil, fmt.Errorf("getting connection grants: %w", err) + } + return &result, nil +} + +// ListAgentsWithGrantsSummary lists agents with each one's attach summary. +func (c *Client) ListAgentsWithGrantsSummary(ctx context.Context, project string) ([]AgentWithGrantsSummary, error) { + var agents []AgentWithGrantsSummary + if err := c.doProject(ctx, http.MethodGet, "/v1/agents?include=grants-summary", project, nil, &agents); err != nil { + return nil, fmt.Errorf("listing agents with grants summary: %w", err) + } + return agents, nil +} + +// normalizeGrantBody shapes the PUT body exactly as the server's schema +// expects: "full" is the bare {"access":"full"}; "custom" carries both allow +// and ask keys as arrays (a nil Go slice marshals to JSON null — normalized +// to [] here). +func normalizeGrantBody(input ConnectionGrantInput) any { + if input.Access != "custom" { + return fullGrantBody{Access: input.Access} + } + body := customGrantBody{Access: "custom", Allow: input.Allow, Ask: input.Ask} + if body.Allow == nil { + body.Allow = []string{} + } + if body.Ask == nil { + body.Ask = []string{} + } + return body +} diff --git a/internal/api/grants_test.go b/internal/api/grants_test.go new file mode 100644 index 0000000..caf37b4 --- /dev/null +++ b/internal/api/grants_test.go @@ -0,0 +1,347 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The grants surface replaces the retired equipment writes, so the properties +// that matter are the PATH (the old twins are 410 now — a wrong path fails at +// runtime, not compile time) and the exact wire BODY (the server's schema +// requires both arrays on a custom grant and rejects null lists). + +func TestGrantsCallTheCanonicalPaths(t *testing.T) { + tests := []struct { + name string + wantMethod string + wantPath string + wantQuery string + body string + call func(*Client) error + }{ + { + name: "get agent grants", + wantMethod: http.MethodGet, + wantPath: "/v1/agents/a1/grants", + body: `{"agentId":"a1","mode":"grants","connections":[],"secrets":[]}`, + call: func(c *Client) error { + _, err := c.GetAgentGrants(context.Background(), "", "a1") + return err + }, + }, + { + name: "attach connection", + wantMethod: http.MethodPut, + wantPath: "/v1/agents/a1/grants/connections/conn-1", + body: `{"agentId":"a1","mode":"grants","connections":[],"secrets":[]}`, + call: func(c *Client) error { + _, err := c.SetAgentConnectionGrant(context.Background(), "", "a1", "conn-1", ConnectionGrantInput{Access: "full"}) + return err + }, + }, + { + name: "detach connection", + wantMethod: http.MethodDelete, + wantPath: "/v1/agents/a1/grants/connections/conn-1", + call: func(c *Client) error { + return c.RemoveAgentConnectionGrant(context.Background(), "", "a1", "conn-1") + }, + }, + { + name: "attach secret", + wantMethod: http.MethodPut, + wantPath: "/v1/agents/a1/grants/secrets/sec-1", + body: `{"agentId":"a1","mode":"grants","connections":[],"secrets":[]}`, + call: func(c *Client) error { + _, err := c.SetAgentSecretGrant(context.Background(), "", "a1", "sec-1") + return err + }, + }, + { + name: "detach secret", + wantMethod: http.MethodDelete, + wantPath: "/v1/agents/a1/grants/secrets/sec-1", + call: func(c *Client) error { + return c.RemoveAgentSecretGrant(context.Background(), "", "a1", "sec-1") + }, + }, + { + name: "get connection grants", + wantMethod: http.MethodGet, + wantPath: "/v1/connections/conn-1/grants", + body: `{"connectionId":"conn-1","agents":[]}`, + call: func(c *Client) error { + _, err := c.GetConnectionGrants(context.Background(), "", "conn-1") + return err + }, + }, + { + name: "agents with grants summary", + wantMethod: http.MethodGet, + wantPath: "/v1/agents", + wantQuery: "include=grants-summary", + body: `[]`, + call: func(c *Client) error { + _, err := c.ListAgentsWithGrantsSummary(context.Background(), "") + return err + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotMethod, gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + if tt.body == "" { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + + c := newWithPrefix(srv.URL, "oc_test", "") + if err := tt.call(c); err != nil { + t.Fatalf("call failed: %v", err) + } + if gotMethod != tt.wantMethod { + t.Errorf("method = %q, want %q", gotMethod, tt.wantMethod) + } + if gotPath != tt.wantPath { + t.Errorf("path = %q, want %q", gotPath, tt.wantPath) + } + if tt.wantQuery != "" && gotQuery != tt.wantQuery { + t.Errorf("query = %q, want %q", gotQuery, tt.wantQuery) + } + }) + } +} + +func TestSetConnectionGrantBodies(t *testing.T) { + tests := []struct { + name string + input ConnectionGrantInput + wantBody string + }{ + { + name: "full sends exactly the access field", + input: ConnectionGrantInput{Access: "full"}, + wantBody: `{"access":"full"}`, + }, + { + name: "custom sends both arrays", + input: ConnectionGrantInput{ + Access: "custom", + Allow: []string{"search_messages"}, + Ask: []string{"send_email"}, + }, + wantBody: `{"access":"custom","allow":["search_messages"],"ask":["send_email"]}`, + }, + { + name: "custom with an empty list sends [] never null or a missing key", + input: ConnectionGrantInput{Access: "custom", Allow: []string{"get_message"}}, + // Ask was nil; the wire must still carry "ask":[] — the server's + // schema requires the key and rejects null. + wantBody: `{"access":"custom","allow":["get_message"],"ask":[]}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + gotBody = string(data) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"agentId":"a1","mode":"grants","connections":[],"secrets":[]}`)) + })) + defer srv.Close() + + c := newWithPrefix(srv.URL, "oc_test", "") + if _, err := c.SetAgentConnectionGrant(context.Background(), "", "a1", "c1", tt.input); err != nil { + t.Fatalf("call failed: %v", err) + } + if gotBody != tt.wantBody { + t.Errorf("body = %s, want %s", gotBody, tt.wantBody) + } + }) + } +} + +func TestSetSecretGrantSendsNoBody(t *testing.T) { + var gotLength int64 + var gotContentType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotLength = r.ContentLength + gotContentType = r.Header.Get("Content-Type") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"agentId":"a1","mode":"grants","connections":[],"secrets":[]}`)) + })) + defer srv.Close() + + c := newWithPrefix(srv.URL, "oc_test", "") + if _, err := c.SetAgentSecretGrant(context.Background(), "", "a1", "s1"); err != nil { + t.Fatalf("call failed: %v", err) + } + if gotLength != 0 { + t.Errorf("content length = %d, want 0 (the secret PUT takes no body)", gotLength) + } + if gotContentType != "" { + t.Errorf("content type = %q, want none", gotContentType) + } +} + +func TestGrantDetachHandles204(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := newWithPrefix(srv.URL, "oc_test", "") + if err := c.RemoveAgentConnectionGrant(context.Background(), "", "a1", "c1"); err != nil { + t.Fatalf("detach connection on 204: %v", err) + } + if err := c.RemoveAgentSecretGrant(context.Background(), "", "a1", "s1"); err != nil { + t.Fatalf("detach secret on 204: %v", err) + } +} + +func TestAgentGrantsDecodesBothArms(t *testing.T) { + label := "Work" + payload, _ := json.Marshal(AgentGrants{ + AgentID: "a1", + Mode: "grants", + Connections: []AgentGrantConnection{{ + ConnectionID: "c1", + Provider: "gmail", + Label: &label, + Scope: "organization", + Access: "custom", + Allow: []string{"search_messages"}, + Ask: []string{"send_email"}, + }}, + Secrets: []AgentGrantSecret{{ + SecretID: "s1", Name: "STRIPE_KEY", Type: "generic", Scope: "project", + }}, + }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(payload) + })) + defer srv.Close() + + c := newWithPrefix(srv.URL, "oc_test", "") + got, err := c.GetAgentGrants(context.Background(), "", "a1") + if err != nil { + t.Fatalf("get grants: %v", err) + } + conn := got.Connections[0] + if conn.Access != "custom" || conn.Scope != "organization" || *conn.Label != "Work" { + t.Errorf("connection arm mangled: %+v", conn) + } + if len(conn.Allow) != 1 || len(conn.Ask) != 1 { + t.Errorf("tool lists mangled: %+v", conn) + } + if got.Secrets[0].Name != "STRIPE_KEY" || got.Secrets[0].Type != "generic" { + t.Errorf("secret arm mangled: %+v", got.Secrets[0]) + } +} + +func TestGrantsSummaryDecodesEntryKinds(t *testing.T) { + body := `[{ + "id": "a1", "name": "Cody", "identifier": "cody", "isDefault": true, + "createdAt": "2026-07-01T00:00:00.000Z", + "grantsSummary": { + "mode": "grants", + "entries": [ + {"kind": "app", "provider": "gmail", "connectionId": "c1", "label": "Work"}, + {"kind": "secret", "id": "s1", "name": "STRIPE_KEY"}, + {"kind": "llm", "id": "s2", "name": "ANTHROPIC_KEY"} + ], + "total": 3 + } + }]` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + c := newWithPrefix(srv.URL, "oc_test", "") + got, err := c.ListAgentsWithGrantsSummary(context.Background(), "") + if err != nil { + t.Fatalf("list with summary: %v", err) + } + a := got[0] + if a.Name != "Cody" || !a.IsDefault { + t.Errorf("embedded Agent mangled: %+v", a.Agent) + } + s := a.GrantsSummary + if s.Total != 3 || len(s.Entries) != 3 { + t.Fatalf("summary mangled: %+v", s) + } + if s.Entries[0].Kind != "app" || s.Entries[0].Provider != "gmail" || *s.Entries[0].Label != "Work" { + t.Errorf("app arm mangled: %+v", s.Entries[0]) + } + if s.Entries[1].Kind != "secret" || s.Entries[1].ID != "s1" { + t.Errorf("secret arm mangled: %+v", s.Entries[1]) + } + if s.Entries[2].Kind != "llm" || s.Entries[2].Name != "ANTHROPIC_KEY" { + t.Errorf("llm arm mangled: %+v", s.Entries[2]) + } +} + +func TestGrantsAreProjectScoped(t *testing.T) { + // A slug resolves to an id via /v1/projects and rides X-Project-Id — the + // header /v1 servers scope grants requests by. + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/projects" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"proj_123","slug":"payments","name":"Payments"}]`)) + return + } + gotHeader = r.Header.Get("X-Project-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"agentId":"a1","mode":"grants","connections":[],"secrets":[]}`)) + })) + defer srv.Close() + + c := newWithPrefix(srv.URL, "oc_test", "") + if _, err := c.GetAgentGrants(context.Background(), "payments", "a1"); err != nil { + t.Fatalf("get grants: %v", err) + } + if gotHeader != "proj_123" { + t.Errorf("X-Project-Id = %q, want %q", gotHeader, "proj_123") + } +} + +func TestGrantErrorPassthrough(t *testing.T) { + const msg = "A tool can't be both always-allowed and require approval." + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"error":{"message":"` + msg + `","type":"validation_error"}}`)) + })) + defer srv.Close() + + c := newWithPrefix(srv.URL, "oc_test", "") + _, err := c.SetAgentConnectionGrant(context.Background(), "", "a1", "c1", ConnectionGrantInput{ + Access: "custom", + Allow: []string{"send_email"}, + Ask: []string{"send_email"}, + }) + if err == nil { + t.Fatal("expected the 422 to surface") + } + if !strings.Contains(err.Error(), msg) { + t.Errorf("error %q does not carry the server message", err) + } +} diff --git a/skills/onecli/SKILL.md b/skills/onecli/SKILL.md index 0480d07..a9c95d5 100644 --- a/skills/onecli/SKILL.md +++ b/skills/onecli/SKILL.md @@ -36,66 +36,106 @@ onecli agents create --name "My Agent" --identifier my-agent # 4. Get the agent ID onecli agents list --quiet id -# 5. Assign the secret to the agent -# Grant a credential by naming the agent in an allow rule; `set-secrets` is retired. -onecli policy rules create --name "Anthropic for worker" --action allow \ - --identities '[{"type":"agent","id":""}]' \ - --targets '[{"kind":"secret","secretId":""}]' +# 5. Attach the secret to the agent (the attach model's write path) +onecli agents grants attach-secret --id --secret-id ``` -### Block an endpoint +### Grant credentials to an agent (the attach model) + +Project access is granted per agent. A grant attaches one credential; the +server compiles grants into policy rules — you never author project rules. ```bash -onecli policy rules create --name "Block Gmail send" --action block \ - --targets '[{"kind":"network","hostPattern":"gmail.googleapis.com","pathPattern":"/gmail/v1/users/me/messages/send","method":"POST"}]' +# Attach an app connection with full access (every catalog tool) +onecli agents grants attach-connection --id --connection-id + +# Attach with per-tool access: --allow always runs, --ask needs approval, +# everything else is blocked. Tool IDs come from the app's catalog: +onecli apps permission-definition --provider gmail +onecli agents grants attach-connection --id --connection-id \ + --allow search_messages,get_message --ask send_email + +# Attach a secret or LLM key (no per-tool axis) +onecli agents grants attach-secret --id --secret-id + +# Read the attach list, and the whole project at once +onecli agents grants list --id +onecli agents list --with-grants ``` -### Rate limit an endpoint +Grant invariants (make these explicit — they are not intuitable): + +- Changes publish IMMEDIATELY — there is no draft or publish step at project + scope. +- A tool cannot be in both `--allow` and `--ask` (422). A grant with every + tool set to Never is a detach — the server rejects it (422); run + `agents grants detach-connection` instead. +- `--ask` (approval-gated tools) requires the approvals feature — a 403 + names the plan; do not retry. +- `agents grants list` is attach INTENT; `agents credentials` is the + EFFECTIVE view with organization guardrails applied. When they disagree, + an org rule is capping the grant. +- Always use `--dry-run` first on mutating grant commands. + +### Block or rate-limit an endpoint (organization rules) + +Org-wide guardrails are policy rules, authored by org admins with an +organization API key: ```bash -onecli policy rules create --name "Limit Anthropic calls" --action allow \ +onecli org policy rules create --name "Block Gmail send" --action block \ + --targets '[{"kind":"network","hostPattern":"gmail.googleapis.com","pathPattern":"/gmail/v1/users/me/messages/send","method":"POST"}]' +onecli org policy rules create --name "Limit Anthropic calls" --action allow \ --rate-limit 100 --rate-limit-window hour \ --targets '[{"kind":"network","hostPattern":"api.anthropic.com"}]' +onecli org policy status ``` -The legacy `rules create` answers 410 on an updated server. +Org policy invariants: -Cloud deployments reject legacy `rules` writes (410) — use the `policy` -family there. Pre-cutover self-hosted servers still accept legacy writes and -reject `policy` writes (403); org policy routes are Cloud/Enterprise-only. +- Org writes land in a DRAFT and AUTO-PUBLISH only when the draft has no + other staged changes; otherwise the publish is withheld (`publishSkipped` + in the output) — review with `org policy status`, then `org policy publish` + or re-run with `--publish-all`. +- Enforced state = `org policy rules list --status published`. Compare rules + across draft/published by `logicalId`, NEVER by `id`. +- `org policy rules reorder --ordered-ids` must name EVERY non-default draft + rule exactly once. -### Manage policy-engine rules (cloud) +Retired surfaces (updated servers answer 410 Gone): the legacy `rules` and +`org rules` families, `org settings`, `agents set-secret-mode` / +`set-secrets` / `connections set`, and ALL project-scope `policy` writes +(`policy rules|default|publish|status`) — project rules are compiled from +grants. -```bash -onecli policy rules create --name "Limit Anthropic calls" --action allow \ - --targets '[{"kind":"network","hostPattern":"api.anthropic.com"}]' \ - --rate-limit 100 --rate-limit-window hour -onecli policy status +### Multiple accounts of one app (gateway 409 protocol) + +When an agent's request could be served by two or more attached accounts of +the same app, the gateway answers `409` with a self-describing JSON body: + +```json +{ + "error": "multiple_connections", + "connections": [{"id": "conn_abc", "label": "Work", "provider": "gmail"}], + "header": "x-onecli-connection-id", + "example": "x-onecli-connection-id: conn_abc" +} ``` -Policy invariants (make these explicit — they are not intuitable): - -- Writes land in a DRAFT and AUTO-PUBLISH only when the draft has no other - staged changes; otherwise the publish is withheld (`publishSkipped` in the - output) — review with `policy status`, then `policy publish` or re-run - with `--publish-all`. A publish snapshots the WHOLE draft, including - changes staged by other users. -- Enforced state = `policy rules list --status published`. Compare rules - across draft/published by `logicalId`, NEVER by `id` (published row ids - regenerate on every publish). -- `policy rules reorder --ordered-ids` must name EVERY non-default draft - rule exactly once (including system-managed blocklist/equipment rows the - web console hides) — take the full list from - `policy rules list --quiet id` (the default is unlimited). -- Always use `--dry-run` first on mutating policy commands. -- On pre-cutover self-hosted servers, `policy rules list` shows a staging - store that is NOT enforced (the legacy model still enforces there). +Retry the identical request with the `x-onecli-connection-id` header set to +one of the listed ids (pick by label/context, or ask the user). Successful +responses advertise the choice list in the `x-onecli-connections` response +header. A `404 connection_not_found` means the chosen id is stale — re-pick +from its `connections` list. Permissions are per account: the same tool can +be allowed on one account and blocked on another. Connection ids also come +from `onecli apps connections list`. ### Check agent configuration ```bash -onecli agents list --fields id,name,secretMode -onecli agents credentials --id # what the policy actually grants +onecli agents list --with-grants +onecli agents grants list --id # attach intent +onecli agents credentials --id # effective (org guardrails applied) ``` ### View and regenerate API key