From d5169f2035bf2f08b281ae403b19fefabcfbd86d Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Fri, 3 Jul 2026 13:12:11 -0700 Subject: [PATCH] feat: align the CLI with the current cloud API and expose its full surface A route-by-route audit against the cloud /v1 API found silent breakage and a season of server capabilities with no CLI exposure. This aligns everything and verifies it call site by call site (70 total). Fixes: - org rules permissions get printed zero-valued garbage: the server moved to the layered {defaults, byAgent} shape; the CLI now decodes it (with a flat-shape fallback for older servers) - apps configure only ever sent clientId/clientSecret, silently saving nothing for github-app; it now sends arbitrary --field key=value pairs (or --json), validated server-side per app - rule actions manual_approval and allow were rejected client-side and conditions could not be sent at all; both supported now - rules update decoded {success:true} into an empty rule; it now re-fetches and prints the real rule (incl. conditions and metadata) - server error envelopes ({error:{message,type}}) were unreadable - messages surfaced now; 403 maps to exit code 5 (documented) - --project resolved nothing server-side: the CLI now resolves slug->id and sends X-Project-Id (plus legacy ?projectId=); NOTE the cloud only honors the header for org keys today - server follow-up filed to honor it for project keys - org rules create/update --agent-id was silently discarded by the server (org rules are agent-less); the flags are gone and JSON payloads carrying agentId fail loudly - response decodes no longer drop fields agents need: rule metadata, secret valueSource/opRef/scope, connection scope, project apiKey (returned exactly once at create and previously lost), app config settings, credential stubs, path-injection configs Connections move to the top-level /v1/connections resource via a probe-once fallback (older deployments keep working on the legacy /v1/apps/connections paths); /v1/org/connections is used directly. New commands: rules permissions get/set (with --agent-id and the inherit setting), rules overlap (both scopes), apps permission-definition, apps connections list/rename, apps config get/toggle, apps configured/env-defaults, apps blocklist (both scopes), agents set-default/granular-access/connections get/set, org connections rename, org settings get/set, vaults list, counts, auth update. The machine-readable help catalog covers all of them. BREAKING (output shape): org rules permissions get now prints the layered object; the previous output was unusable zero-values. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- cmd/onecli/agents.go | 102 +++++++++++++ cmd/onecli/apps.go | 182 +++++++++++++++++++---- cmd/onecli/auth.go | 25 ++++ cmd/onecli/blocklist.go | 242 +++++++++++++++++++++++++++++++ cmd/onecli/fields_test.go | 114 +++++++++++++++ cmd/onecli/help.go | 123 +++++++++++++++- cmd/onecli/main.go | 7 +- cmd/onecli/org.go | 1 + cmd/onecli/org_apps.go | 39 ++--- cmd/onecli/org_connections.go | 52 ++++--- cmd/onecli/org_rules.go | 44 +++++- cmd/onecli/resources.go | 187 ++++++++++++++++++++++++ cmd/onecli/rules.go | 195 +++++++++++++++++++++++-- internal/api/agents.go | 46 +++++- internal/api/alignment_test.go | 249 ++++++++++++++++++++++++++++++++ internal/api/apps.go | 91 +++++++----- internal/api/blocklist.go | 62 ++++++++ internal/api/client.go | 125 +++++++++++++++- internal/api/connections.go | 59 ++++++++ internal/api/org_apps.go | 9 +- internal/api/org_connections.go | 47 +++--- internal/api/org_rules.go | 54 +++++-- internal/api/projects.go | 5 +- internal/api/resources.go | 111 ++++++++++++++ internal/api/rules.go | 122 +++++++++++----- internal/api/secrets.go | 37 +++-- internal/api/user.go | 10 ++ pkg/exitcode/exitcode.go | 2 + skills/onecli/SKILL.md | 2 +- 30 files changed, 2117 insertions(+), 229 deletions(-) create mode 100644 cmd/onecli/blocklist.go create mode 100644 cmd/onecli/fields_test.go create mode 100644 cmd/onecli/resources.go create mode 100644 internal/api/alignment_test.go create mode 100644 internal/api/blocklist.go create mode 100644 internal/api/connections.go create mode 100644 internal/api/resources.go diff --git a/CLAUDE.md b/CLAUDE.md index 2a0bd92..684f6ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ pkg/validate/ # Input hardening at command boundaries ## Shared Packages - `pkg/output` — all output goes through `output.Writer`. Never use `fmt.Print` or `os.Stdout`. -- `pkg/exitcode` — exit codes and string codes. Map API 401 → AuthRequired, 404 → NotFound, 409 → Conflict. +- `pkg/exitcode` — exit codes and string codes. Map API 401 → AuthRequired, 403 → Forbidden, 404 → NotFound, 409 → Conflict. - `pkg/validate` — validate resource IDs, URLs, API keys at command boundaries. ## Go Conventions diff --git a/cmd/onecli/agents.go b/cmd/onecli/agents.go index cb9d5f0..576ab9c 100644 --- a/cmd/onecli/agents.go +++ b/cmd/onecli/agents.go @@ -14,6 +14,7 @@ import ( type AgentsCmd struct { List AgentsListCmd `cmd:"" help:"List all agents."` GetDefault AgentsGetDefaultCmd `cmd:"" name:"get-default" help:"Get the default agent."` + SetDefault AgentsSetDefaultCmd `cmd:"" name:"set-default" help:"Mark an agent as the project default."` Create AgentsCreateCmd `cmd:"" help:"Create a new agent."` Delete AgentsDeleteCmd `cmd:"" help:"Delete an agent."` Rename AgentsRenameCmd `cmd:"" help:"Rename an agent."` @@ -21,6 +22,107 @@ type AgentsCmd struct { 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."` +} + +// AgentsSetDefaultCmd is `onecli agents set-default`. +type AgentsSetDefaultCmd struct { + ID string `required:"" help:"ID of the agent."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AgentsSetDefaultCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid agent ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would set default agent", map[string]string{"id": c.ID}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.SetDefaultAgent(newContext(), c.ID); err != nil { + return err + } + return out.Write(map[string]string{"status": "default", "id": c.ID}) +} + +// AgentsGranularAccessCmd is `onecli agents granular-access`. +type AgentsGranularAccessCmd struct { + 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."` +} + +func (c *AgentsGranularAccessCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + entries, err := client.ListGranularAccess(newContext()) + if err != nil { + return err + } + if c.Quiet != "" { + return out.WriteQuiet(entries, c.Quiet) + } + return out.WriteFiltered(entries, c.Fields) +} + +// 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)."` +} + +// AgentsConnectionsGetCmd is `onecli agents connections get`. +type AgentsConnectionsGetCmd struct { + ID string `required:"" help:"ID of the agent."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *AgentsConnectionsGetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid agent ID: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + connections, err := client.GetAgentConnections(newContext(), c.ID) + if err != nil { + return err + } + return out.WriteFiltered(connections, c.Fields) +} + +// AgentsConnectionsSetCmd is `onecli agents connections set`. +type AgentsConnectionsSetCmd struct { + ID string `required:"" help:"ID of the agent."` + Json string `required:"" help:"JSON array of connection assignments (the API's 'connections' array, incl. optional granular-access policies)."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AgentsConnectionsSetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid agent ID: %w", err) + } + var connections []any + if err := json.Unmarshal([]byte(c.Json), &connections); err != nil { + return fmt.Errorf("invalid JSON payload (expected an array): %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would set agent connections", map[string]any{"id": c.ID, "connections": connections}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.SetAgentConnections(newContext(), c.ID, connections); err != nil { + return err + } + return out.Write(map[string]any{"status": "updated", "id": c.ID, "connectionCount": len(connections)}) } // AgentsListCmd is `onecli agents list`. diff --git a/cmd/onecli/apps.go b/cmd/onecli/apps.go index 5d7be31..1649129 100644 --- a/cmd/onecli/apps.go +++ b/cmd/onecli/apps.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "strings" "github.com/onecli/onecli-cli/internal/api" "github.com/onecli/onecli-cli/pkg/output" @@ -17,11 +18,103 @@ type configureResult struct { // AppsCmd is the `onecli apps` command group. type AppsCmd struct { - List AppsListCmd `cmd:"" help:"List all apps with config and connection status."` - Get AppsGetCmd `cmd:"" help:"Get a single app with setup guidance."` - Configure AppsConfigureCmd `cmd:"" help:"Save OAuth credentials (BYOC) for a provider."` - Remove AppsRemoveCmd `cmd:"" help:"Remove OAuth credentials for a provider."` - Disconnect AppsDisconnectCmd `cmd:"" help:"Disconnect an app connection."` + List AppsListCmd `cmd:"" help:"List all apps with config and connection status."` + Get AppsGetCmd `cmd:"" help:"Get a single app with setup guidance."` + Configure AppsConfigureCmd `cmd:"" help:"Save credentials (BYOC) for a provider."` + Remove AppsRemoveCmd `cmd:"" help:"Remove BYOC credentials for a provider."` + Disconnect AppsDisconnectCmd `cmd:"" help:"Disconnect an app connection."` + Connections AppsConnectionsCmd `cmd:"" help:"Manage app connections."` + PermissionDefinition AppsPermissionDefinitionCmd `cmd:"" name:"permission-definition" help:"Show an app's tool catalog (groups + toolIds) for permission rules."` + Config AppsConfigCmd `cmd:"" help:"Inspect and toggle a provider's BYOC config."` + Configured AppsConfiguredCmd `cmd:"" help:"List providers with an enabled config."` + EnvDefaults AppsEnvDefaultsCmd `cmd:"" name:"env-defaults" help:"List providers with platform default credentials."` + Blocklist AppsBlocklistCmd `cmd:"" help:"Manage an app's endpoint blocklist."` +} + +// AppsConnectionsCmd is the `onecli apps connections` command group. +type AppsConnectionsCmd struct { + List AppsConnectionsListCmd `cmd:"" help:"List app connections, optionally filtered by provider."` + Rename AppsConnectionsRenameCmd `cmd:"" help:"Rename an app connection."` +} + +// AppsConnectionsListCmd is `onecli apps connections list`. +type AppsConnectionsListCmd struct { + Provider string `optional:"" help:"Filter by provider name (e.g. 'github', 'gmail')."` + 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."` +} + +func (c *AppsConnectionsListCmd) Run(out *output.Writer) error { + if c.Provider != "" { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + } + client, err := newClient() + if err != nil { + return err + } + connections, err := client.ListConnections(newContext(), c.Provider) + if err != nil { + return err + } + if c.Max > 0 && len(connections) > c.Max { + connections = connections[:c.Max] + } + if c.Quiet != "" { + return out.WriteQuiet(connections, c.Quiet) + } + return out.WriteFiltered(connections, c.Fields) +} + +// AppsConnectionsRenameCmd is `onecli apps connections rename`. +type AppsConnectionsRenameCmd struct { + ID string `required:"" help:"ID of the connection to rename."` + Label string `required:"" help:"New display label."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AppsConnectionsRenameCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid connection ID: %w", err) + } + if c.Label == "" { + return fmt.Errorf("label must not be empty") + } + if c.DryRun { + return out.WriteDryRun("Would rename connection", map[string]string{"id": c.ID, "label": c.Label}) + } + client, err := newClient() + if err != nil { + return err + } + conn, err := client.RenameConnection(newContext(), c.ID, c.Label) + if err != nil { + return err + } + return out.Write(conn) +} + +// AppsPermissionDefinitionCmd is `onecli apps permission-definition`. +type AppsPermissionDefinitionCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *AppsPermissionDefinitionCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + def, err := client.GetPermissionDefinition(newContext(), c.Provider) + if err != nil { + return err + } + return out.WriteFiltered(def, c.Fields) } // AppsListCmd is `onecli apps list`. @@ -77,46 +170,75 @@ func (c *AppsGetCmd) Run(out *output.Writer) error { return out.WriteFiltered(app, c.Fields) } +// buildConfigFields assembles the credential fields for an app configure +// command from, in precedence order: a raw --json object, repeated +// --field key=value flags, and the --client-id/--client-secret sugar for +// OAuth-style apps. Field names must match the app's own configurable field +// definitions (e.g. github-app uses appId/appSlug/privateKey). +func buildConfigFields(jsonPayload string, fieldFlags []string, clientID, clientSecret string) (api.ConfigFields, error) { + fields := api.ConfigFields{} + if jsonPayload != "" { + if err := json.Unmarshal([]byte(jsonPayload), &fields); err != nil { + return nil, fmt.Errorf("invalid JSON payload (expected an object of string fields): %w", err) + } + } + for _, f := range fieldFlags { + key, value, ok := strings.Cut(f, "=") + if !ok || key == "" { + return nil, fmt.Errorf("invalid --field %q: expected key=value", f) + } + fields[key] = value + } + if clientID != "" { + fields["clientId"] = clientID + } + if clientSecret != "" { + fields["clientSecret"] = clientSecret + } + if len(fields) == 0 { + return nil, fmt.Errorf("no credential fields provided: use --field key=value (repeatable), --json, or --client-id/--client-secret") + } + return fields, nil +} + +// maskedFieldPreview lists the field names being set with masked values, so +// dry-run output never echoes secrets. +func maskedFieldPreview(provider string, fields api.ConfigFields) map[string]string { + preview := map[string]string{"provider": provider} + for key := range fields { + preview[key] = "***" + } + return preview +} + // AppsConfigureCmd is `onecli apps configure`. type AppsConfigureCmd struct { - Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` - ClientID string `required:"" name:"client-id" help:"OAuth client ID."` - ClientSecret string `required:"" name:"client-secret" help:"OAuth client secret."` - Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` - DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + Field []string `optional:"" help:"Credential field as key=value (repeatable); names per the app's field definitions."` + ClientID string `optional:"" name:"client-id" help:"OAuth client ID (shorthand for --field clientId=...)."` + ClientSecret string `optional:"" name:"client-secret" help:"OAuth client secret (shorthand for --field clientSecret=...)."` + Json string `optional:"" help:"Raw JSON object of credential fields. Merged first; flags override."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` } func (c *AppsConfigureCmd) Run(out *output.Writer) error { - var input api.ConfigAppInput - if c.Json != "" { - if err := json.Unmarshal([]byte(c.Json), &input); err != nil { - return fmt.Errorf("invalid JSON payload: %w", err) - } - } else { - input = api.ConfigAppInput{ - ClientID: c.ClientID, - ClientSecret: c.ClientSecret, - } - } - if err := validate.ResourceID(c.Provider); err != nil { return fmt.Errorf("invalid provider: %w", err) } + fields, err := buildConfigFields(c.Json, c.Field, c.ClientID, c.ClientSecret) + if err != nil { + return err + } if c.DryRun { - preview := map[string]string{ - "provider": c.Provider, - "clientId": input.ClientID, - "clientSecret": "***", - } - return out.WriteDryRun("Would configure app", preview) + return out.WriteDryRun("Would configure app", maskedFieldPreview(c.Provider, fields)) } client, err := newClient() if err != nil { return err } - if err := client.ConfigureApp(newContext(), c.Provider, input); err != nil { + if err := client.ConfigureApp(newContext(), c.Provider, fields); err != nil { return err } @@ -176,7 +298,7 @@ func (c *AppsDisconnectCmd) Run(out *output.Writer) error { connectionID := c.ConnectionID if connectionID == "" { - connections, err := client.ListConnectionsByProvider(newContext(), c.Provider) + connections, err := client.ListConnections(newContext(), c.Provider) if err != nil { return err } diff --git a/cmd/onecli/auth.go b/cmd/onecli/auth.go index 8f94729..6274423 100644 --- a/cmd/onecli/auth.go +++ b/cmd/onecli/auth.go @@ -20,10 +20,35 @@ type AuthCmd struct { Login AuthLoginCmd `cmd:"" help:"Store API key for authentication."` Logout AuthLogoutCmd `cmd:"" help:"Remove stored API key."` Status AuthStatusCmd `cmd:"" help:"Show authentication status."` + Update AuthUpdateCmd `cmd:"" help:"Update your profile (display name)."` ApiKey AuthApiKeyCmd `cmd:"api-key" help:"Show your current API key."` RegenerateApiKey AuthRegenerateApiKeyCmd `cmd:"regenerate-api-key" help:"Regenerate your API key."` } +// AuthUpdateCmd is `onecli auth update`. +type AuthUpdateCmd struct { + Name string `required:"" help:"New display name."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AuthUpdateCmd) Run(out *output.Writer) error { + if c.Name == "" { + return fmt.Errorf("name must not be empty") + } + if c.DryRun { + return out.WriteDryRun("Would update profile", map[string]string{"name": c.Name}) + } + client, err := newClient() + if err != nil { + return err + } + user, err := client.UpdateProfile(newContext(), c.Name) + if err != nil { + return err + } + return out.Write(user) +} + // AuthLoginCmd is `onecli auth login`. type AuthLoginCmd struct { APIKey string `optional:"" name:"api-key" help:"API key to store (oc_... format)."` diff --git a/cmd/onecli/blocklist.go b/cmd/onecli/blocklist.go new file mode 100644 index 0000000..0787450 --- /dev/null +++ b/cmd/onecli/blocklist.go @@ -0,0 +1,242 @@ +package main + +import ( + "fmt" + + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// Blocklist command runners shared by the project (`apps blocklist`) and org +// (`org apps blocklist`) groups — the org flag picks the API scope. + +func runBlocklistList(out *output.Writer, provider, fields string, org bool) error { + if err := validate.ResourceID(provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + states, err := client.GetBlocklist(newContext(), provider, org) + if err != nil { + return err + } + return out.WriteFiltered(states, fields) +} + +func runBlocklistActivate(out *output.Writer, provider, hostID string, dryRun, org bool) error { + if err := validate.ResourceID(provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + if hostID == "" { + return fmt.Errorf("host-id must not be empty") + } + if dryRun { + return out.WriteDryRun("Would activate blocklist host", map[string]string{"provider": provider, "hostId": hostID}) + } + client, err := newClient() + if err != nil { + return err + } + result, err := client.ActivateBlocklistHost(newContext(), provider, hostID, org) + if err != nil { + return err + } + return out.Write(result) +} + +func runBlocklistAdd(out *output.Writer, provider, name, hostPattern string, dryRun, org bool) error { + if err := validate.ResourceID(provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + if name == "" || hostPattern == "" { + return fmt.Errorf("--name and --host-pattern are required") + } + if dryRun { + return out.WriteDryRun("Would add blocklist rule", map[string]string{"provider": provider, "name": name, "hostPattern": hostPattern}) + } + client, err := newClient() + if err != nil { + return err + } + result, err := client.AddBlocklistRule(newContext(), provider, name, hostPattern, org) + if err != nil { + return err + } + return out.Write(result) +} + +func runBlocklistToggle(out *output.Writer, provider, ruleID string, enabled, dryRun, org bool) error { + if err := validate.ResourceID(provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + if err := validate.ResourceID(ruleID); err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + if dryRun { + return out.WriteDryRun("Would toggle blocklist rule", map[string]any{"provider": provider, "ruleId": ruleID, "enabled": enabled}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.ToggleBlocklistRule(newContext(), provider, ruleID, enabled, org); err != nil { + return err + } + status := "disabled" + if enabled { + status = "enabled" + } + return out.Write(map[string]string{"status": status, "ruleId": ruleID}) +} + +func runBlocklistRemove(out *output.Writer, provider, ruleID string, dryRun, org bool) error { + if err := validate.ResourceID(provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + if err := validate.ResourceID(ruleID); err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + if dryRun { + return out.WriteDryRun("Would remove blocklist rule", map[string]string{"provider": provider, "ruleId": ruleID}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.RemoveBlocklistRule(newContext(), provider, ruleID, org); err != nil { + return err + } + return out.Write(map[string]string{"status": "removed", "ruleId": ruleID}) +} + +// AppsBlocklistCmd is the `onecli apps blocklist` command group (project). +type AppsBlocklistCmd struct { + List AppsBlocklistListCmd `cmd:"" help:"Show blocklist state for a provider."` + Activate AppsBlocklistActivateCmd `cmd:"" help:"Activate a predefined blocklist host."` + Add AppsBlocklistAddCmd `cmd:"" help:"Add a custom blocklist rule."` + Toggle AppsBlocklistToggleCmd `cmd:"" help:"Enable or disable a blocklist rule."` + Remove AppsBlocklistRemoveCmd `cmd:"" help:"Remove a blocklist rule."` +} + +// AppsBlocklistListCmd is `onecli apps blocklist list`. +type AppsBlocklistListCmd struct { + Provider string `required:"" help:"Provider name."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *AppsBlocklistListCmd) Run(out *output.Writer) error { + return runBlocklistList(out, c.Provider, c.Fields, false) +} + +// AppsBlocklistActivateCmd is `onecli apps blocklist activate`. +type AppsBlocklistActivateCmd struct { + Provider string `required:"" help:"Provider name."` + HostID string `required:"" name:"host-id" help:"Predefined blocklist host ID."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AppsBlocklistActivateCmd) Run(out *output.Writer) error { + return runBlocklistActivate(out, c.Provider, c.HostID, c.DryRun, false) +} + +// AppsBlocklistAddCmd is `onecli apps blocklist add`. +type AppsBlocklistAddCmd struct { + Provider string `required:"" help:"Provider name."` + Name string `required:"" help:"Display name for the rule."` + HostPattern string `required:"" name:"host-pattern" help:"Host pattern to block."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AppsBlocklistAddCmd) Run(out *output.Writer) error { + return runBlocklistAdd(out, c.Provider, c.Name, c.HostPattern, c.DryRun, false) +} + +// AppsBlocklistToggleCmd is `onecli apps blocklist toggle`. +type AppsBlocklistToggleCmd struct { + Provider string `required:"" help:"Provider name."` + RuleID string `required:"" name:"rule-id" help:"Blocklist rule ID."` + Enabled bool `required:"" help:"Set to true to enable, false to disable."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AppsBlocklistToggleCmd) Run(out *output.Writer) error { + return runBlocklistToggle(out, c.Provider, c.RuleID, c.Enabled, c.DryRun, false) +} + +// AppsBlocklistRemoveCmd is `onecli apps blocklist remove`. +type AppsBlocklistRemoveCmd struct { + Provider string `required:"" help:"Provider name."` + RuleID string `required:"" name:"rule-id" help:"Blocklist rule ID."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AppsBlocklistRemoveCmd) Run(out *output.Writer) error { + return runBlocklistRemove(out, c.Provider, c.RuleID, c.DryRun, false) +} + +// OrgAppsBlocklistCmd is the `onecli org apps blocklist` command group. +type OrgAppsBlocklistCmd struct { + List OrgAppsBlocklistListCmd `cmd:"" help:"Show org blocklist state for a provider."` + Activate OrgAppsBlocklistActivateCmd `cmd:"" help:"Activate a predefined blocklist host (org)."` + Add OrgAppsBlocklistAddCmd `cmd:"" help:"Add a custom blocklist rule (org)."` + Toggle OrgAppsBlocklistToggleCmd `cmd:"" help:"Enable or disable a blocklist rule (org)."` + Remove OrgAppsBlocklistRemoveCmd `cmd:"" help:"Remove a blocklist rule (org)."` +} + +// OrgAppsBlocklistListCmd is `onecli org apps blocklist list`. +type OrgAppsBlocklistListCmd struct { + Provider string `required:"" help:"Provider name."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *OrgAppsBlocklistListCmd) Run(out *output.Writer) error { + return runBlocklistList(out, c.Provider, c.Fields, true) +} + +// OrgAppsBlocklistActivateCmd is `onecli org apps blocklist activate`. +type OrgAppsBlocklistActivateCmd struct { + Provider string `required:"" help:"Provider name."` + HostID string `required:"" name:"host-id" help:"Predefined blocklist host ID."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgAppsBlocklistActivateCmd) Run(out *output.Writer) error { + return runBlocklistActivate(out, c.Provider, c.HostID, c.DryRun, true) +} + +// OrgAppsBlocklistAddCmd is `onecli org apps blocklist add`. +type OrgAppsBlocklistAddCmd struct { + Provider string `required:"" help:"Provider name."` + Name string `required:"" help:"Display name for the rule."` + HostPattern string `required:"" name:"host-pattern" help:"Host pattern to block."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgAppsBlocklistAddCmd) Run(out *output.Writer) error { + return runBlocklistAdd(out, c.Provider, c.Name, c.HostPattern, c.DryRun, true) +} + +// OrgAppsBlocklistToggleCmd is `onecli org apps blocklist toggle`. +type OrgAppsBlocklistToggleCmd struct { + Provider string `required:"" help:"Provider name."` + RuleID string `required:"" name:"rule-id" help:"Blocklist rule ID."` + Enabled bool `required:"" help:"Set to true to enable, false to disable."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgAppsBlocklistToggleCmd) Run(out *output.Writer) error { + return runBlocklistToggle(out, c.Provider, c.RuleID, c.Enabled, c.DryRun, true) +} + +// OrgAppsBlocklistRemoveCmd is `onecli org apps blocklist remove`. +type OrgAppsBlocklistRemoveCmd struct { + Provider string `required:"" help:"Provider name."` + RuleID string `required:"" name:"rule-id" help:"Blocklist rule ID."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgAppsBlocklistRemoveCmd) Run(out *output.Writer) error { + return runBlocklistRemove(out, c.Provider, c.RuleID, c.DryRun, true) +} diff --git a/cmd/onecli/fields_test.go b/cmd/onecli/fields_test.go new file mode 100644 index 0000000..330fd9b --- /dev/null +++ b/cmd/onecli/fields_test.go @@ -0,0 +1,114 @@ +package main + +import "testing" + +func TestBuildConfigFields(t *testing.T) { + tests := []struct { + name string + jsonPayload string + fieldFlags []string + clientID string + clientSecret string + want map[string]string + wantErr bool + }{ + { + name: "repeated field flags", + fieldFlags: []string{"appId=123", "appSlug=my-app", "privateKey=pem"}, + want: map[string]string{"appId": "123", "appSlug": "my-app", "privateKey": "pem"}, + }, + { + name: "client id/secret sugar", + clientID: "id", + clientSecret: "sec", + want: map[string]string{"clientId": "id", "clientSecret": "sec"}, + }, + { + name: "json payload merged with flag override", + jsonPayload: `{"appId":"json","appSlug":"slug"}`, + fieldFlags: []string{"appId=flag"}, + want: map[string]string{"appId": "flag", "appSlug": "slug"}, + }, + { + name: "value containing equals sign", + fieldFlags: []string{"privateKey=a=b=c"}, + want: map[string]string{"privateKey": "a=b=c"}, + }, + { + name: "no fields at all", + wantErr: true, + }, + { + name: "malformed field flag", + fieldFlags: []string{"no-equals"}, + wantErr: true, + }, + { + name: "non-object json", + jsonPayload: `["not","an","object"]`, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildConfigFields(tt.jsonPayload, tt.fieldFlags, tt.clientID, tt.clientSecret) + if tt.wantErr { + if err == nil { + t.Fatalf("want error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("fields = %v, want %v", got, tt.want) + } + for k, v := range tt.want { + if got[k] != v { + t.Errorf("fields[%q] = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestParseConditions(t *testing.T) { + conditions, err := parseConditions(`[{"target":"body","operator":"contains","value":"x"}]`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conditions) != 1 || conditions[0].Value != "x" { + t.Errorf("conditions = %+v, want one body-contains-x", conditions) + } + + if got, err := parseConditions(""); err != nil || got != nil { + t.Errorf("empty input = (%v, %v), want (nil, nil)", got, err) + } + + if _, err := parseConditions(`{"not":"an array"}`); err == nil { + t.Error("want error for non-array input") + } +} + +func TestValidRuleActions(t *testing.T) { + for _, action := range []string{"block", "rate_limit", "manual_approval", "allow"} { + if !validRuleActions[action] { + t.Errorf("action %q should be valid", action) + } + } + if validRuleActions["inherit"] { + t.Error("'inherit' is a permission setting, not a rule action") + } +} + +func TestValidPermissionSettings(t *testing.T) { + for _, p := range []string{"allow", "manual_approval", "block", "inherit"} { + if !validPermissionSettings[p] { + t.Errorf("permission %q should be valid", p) + } + } + if validPermissionSettings["rate_limit"] { + t.Error("'rate_limit' is a rule action, not a permission setting") + } +} diff --git a/cmd/onecli/help.go b/cmd/onecli/help.go index a99ea39..ef65355 100644 --- a/cmd/onecli/help.go +++ b/cmd/onecli/help.go @@ -73,6 +73,17 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--id", Required: true, Description: "ID of the agent."}, {Name: "--secret-ids", Required: true, Description: "Comma-separated list of secret IDs."}, }}, + {Name: "agents set-default", Description: "Mark an agent as the project default.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the agent."}, + }}, + {Name: "agents granular-access", Description: "Show per-agent granular-access policies across the project."}, + {Name: "agents connections get", Description: "Get an agent's app-connection assignments.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the agent."}, + }}, + {Name: "agents connections set", Description: "Replace an agent's app-connection assignments.", 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: "--id", Required: true, Description: "ID of the agent."}, {Name: "--mode", Required: true, Description: "Secret mode: 'all' or 'selective'."}, @@ -97,17 +108,59 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "apps get", Description: "Get a single app with setup guidance.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, }}, - {Name: "apps configure", Description: "Save OAuth credentials (BYOC) for a provider.", Args: []ArgInfo{ + {Name: "apps configure", Description: "Save credentials (BYOC) for a provider.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, - {Name: "--client-id", Required: true, Description: "OAuth client ID."}, - {Name: "--client-secret", Required: true, Description: "OAuth client secret."}, + {Name: "--field", Description: "Credential field as key=value (repeatable); names per the app's field definitions."}, + {Name: "--client-id", Description: "OAuth client ID (shorthand for --field clientId=...)."}, + {Name: "--client-secret", Description: "OAuth client secret (shorthand for --field clientSecret=...)."}, + {Name: "--json", Description: "Raw JSON object of credential fields."}, }}, - {Name: "apps remove", Description: "Remove OAuth credentials for a provider.", Args: []ArgInfo{ + {Name: "apps remove", Description: "Remove BYOC credentials for a provider.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, }}, {Name: "apps disconnect", Description: "Disconnect an app connection.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, }}, + {Name: "apps connections list", Description: "List app connections, optionally filtered by provider.", Args: []ArgInfo{ + {Name: "--provider", Description: "Filter by provider name."}, + }}, + {Name: "apps connections rename", Description: "Rename an app connection.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the connection."}, + {Name: "--label", Required: true, Description: "New display label."}, + }}, + {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')."}, + }}, + {Name: "apps config get", Description: "Get config status for a provider.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + }}, + {Name: "apps config toggle", Description: "Enable or disable a provider's config.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--enabled", Required: true, Description: "Set to true to enable, false to disable."}, + }}, + {Name: "apps configured", Description: "List providers with an enabled config."}, + {Name: "apps env-defaults", Description: "List providers with platform default credentials."}, + {Name: "apps blocklist list", Description: "Show blocklist state for a provider.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + }}, + {Name: "apps blocklist activate", Description: "Activate a predefined blocklist host.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--host-id", Required: true, Description: "Predefined blocklist host ID."}, + }}, + {Name: "apps blocklist add", Description: "Add a custom blocklist rule.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--name", Required: true, Description: "Display name for the rule."}, + {Name: "--host-pattern", Required: true, Description: "Host pattern to block."}, + }}, + {Name: "apps blocklist toggle", Description: "Enable or disable a blocklist rule.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--rule-id", Required: true, Description: "Blocklist rule ID."}, + {Name: "--enabled", Required: true, Description: "Set to true to enable, false to disable."}, + }}, + {Name: "apps blocklist remove", Description: "Remove a blocklist rule.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--rule-id", Required: true, Description: "Blocklist rule ID."}, + }}, {Name: "rules list", Description: "List all policy rules.", Args: []ArgInfo{ {Name: "--project, -p", Description: "Project slug."}, }}, @@ -115,7 +168,8 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {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' or 'rate_limit'."}, + {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: "Update an existing policy rule.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to update."}, @@ -123,6 +177,20 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "rules delete", Description: "Delete a policy rule.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to delete."}, }}, + {Name: "rules permissions get", Description: "Get layered tool permissions for a provider.", 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: "Set tool permissions for a provider (optionally per agent).", 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)."}, + {Name: "--agent-id", Description: "Target one agent's override layer."}, + {Name: "--json", Description: "Raw JSON payload with 'changes' array."}, + }}, + {Name: "rules overlap", Description: "Count custom rules overlapping an app's hosts.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + }}, {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."}, @@ -157,7 +225,7 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "org rules create", Description: "Create a new org-scoped policy rule.", Args: []ArgInfo{ {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' or 'rate_limit'."}, + {Name: "--action", Required: true, Description: "Action: 'block', 'rate_limit', 'manual_approval', or 'allow'."}, }}, {Name: "org rules update", Description: "Update an org-scoped policy rule.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to update."}, @@ -172,9 +240,16 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, {Name: "--json", Required: true, Description: "JSON payload with 'changes' array."}, }}, + {Name: "org rules overlap", Description: "Count custom org rules overlapping an app's hosts.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + }}, {Name: "org connections list", Description: "List all org-scoped connections.", Args: []ArgInfo{ {Name: "--provider", Description: "Filter by provider name."}, }}, + {Name: "org connections rename", Description: "Rename an org-scoped connection.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the connection."}, + {Name: "--label", Required: true, Description: "New display label."}, + }}, {Name: "org connections delete", Description: "Delete an org-scoped connection.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the connection to delete."}, }}, @@ -184,8 +259,10 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { }}, {Name: "org apps configure", Description: "Save BYOC credentials at the org level.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, - {Name: "--client-id", Required: true, Description: "OAuth client ID."}, - {Name: "--client-secret", Required: true, Description: "OAuth client secret."}, + {Name: "--field", Description: "Credential field as key=value (repeatable)."}, + {Name: "--client-id", Description: "OAuth client ID (shorthand)."}, + {Name: "--client-secret", Description: "OAuth client secret (shorthand)."}, + {Name: "--json", Description: "Raw JSON object of credential fields."}, }}, {Name: "org apps remove", Description: "Remove BYOC credentials at the org level.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, @@ -194,9 +271,39 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, {Name: "--enabled", Required: true, Description: "Set to true to enable, false to disable."}, }}, + {Name: "org apps blocklist list", Description: "Show org blocklist state for a provider.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + }}, + {Name: "org apps blocklist activate", Description: "Activate a predefined blocklist host (org).", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--host-id", Required: true, Description: "Predefined blocklist host ID."}, + }}, + {Name: "org apps blocklist add", Description: "Add a custom blocklist rule (org).", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--name", Required: true, Description: "Display name for the rule."}, + {Name: "--host-pattern", Required: true, Description: "Host pattern to block."}, + }}, + {Name: "org apps blocklist toggle", Description: "Enable or disable a blocklist rule (org).", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--rule-id", Required: true, Description: "Blocklist rule ID."}, + {Name: "--enabled", Required: true, Description: "Set to true to enable, false to disable."}, + }}, + {Name: "org apps blocklist remove", Description: "Remove a blocklist rule (org).", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name."}, + {Name: "--rule-id", Required: true, Description: "Blocklist rule ID."}, + }}, + {Name: "org settings get", Description: "Get organization settings (policy mode)."}, + {Name: "org settings set", Description: "Update organization settings.", Args: []ArgInfo{ + {Name: "--policy-mode", Required: true, Description: "Policy mode: 'allow' or 'deny'."}, + }}, + {Name: "vaults list", Description: "List external vault connections (e.g. 1Password)."}, + {Name: "counts", Description: "Show the project's resource counts."}, {Name: "auth login", Description: "Store API key for authentication."}, {Name: "auth logout", Description: "Remove stored API key."}, {Name: "auth status", Description: "Show authentication status."}, + {Name: "auth update", Description: "Update your profile (display name).", Args: []ArgInfo{ + {Name: "--name", Required: true, Description: "New display name."}, + }}, {Name: "auth api-key", Description: "Show your current API key."}, {Name: "auth regenerate-api-key", Description: "Regenerate your API key."}, {Name: "config get ", Description: "Get a config value."}, diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index 074565a..36a3f67 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -29,7 +29,9 @@ type CLI struct { Apps AppsCmd `cmd:"" help:"Manage app connections."` Rules RulesCmd `cmd:"" help:"Manage policy rules."` Projects ProjectsCmd `cmd:"" help:"Manage projects."` - Org OrgCmd `cmd:"" help:"Organization-scoped management (secrets, rules, connections, apps)."` + Org OrgCmd `cmd:"" help:"Organization-scoped management (secrets, rules, connections, apps, settings)."` + Vaults VaultsCmd `cmd:"" help:"List external vault connections."` + Counts CountsCmd `cmd:"" help:"Show the project's resource counts."` Auth AuthCmd `cmd:"" help:"Manage authentication."` Config ConfigCmd `cmd:"" help:"Manage configuration settings."` Migrate MigrateCmd `cmd:"" help:"Migrate data to OneCLI Cloud."` @@ -85,6 +87,9 @@ func handleError(out *output.Writer, err error) { case 401: _ = out.ErrorWithAction(exitcode.CodeAuthRequired, apiErr.Message, "onecli auth login") os.Exit(exitcode.AuthRequired) + case 403: + _ = out.Error(exitcode.CodeForbidden, apiErr.Message) + os.Exit(exitcode.Forbidden) case 404: _ = out.Error(exitcode.CodeNotFound, apiErr.Message) os.Exit(exitcode.NotFound) diff --git a/cmd/onecli/org.go b/cmd/onecli/org.go index e6e0af2..21b996b 100644 --- a/cmd/onecli/org.go +++ b/cmd/onecli/org.go @@ -6,4 +6,5 @@ type OrgCmd struct { Rules OrgRulesCmd `cmd:"" help:"Manage org-scoped policy rules."` 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)."` } diff --git a/cmd/onecli/org_apps.go b/cmd/onecli/org_apps.go index e823356..d893686 100644 --- a/cmd/onecli/org_apps.go +++ b/cmd/onecli/org_apps.go @@ -1,10 +1,8 @@ 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" ) @@ -16,6 +14,7 @@ type OrgAppsCmd struct { Configure OrgAppsConfigureCmd `cmd:"" help:"Save BYOC credentials for a provider at the org level."` Remove OrgAppsRemoveCmd `cmd:"" help:"Remove BYOC credentials for a provider at the org level."` Toggle OrgAppsToggleCmd `cmd:"" help:"Enable or disable an app config at the org level."` + Blocklist OrgAppsBlocklistCmd `cmd:"" help:"Manage an app's endpoint blocklist at the org level."` } // OrgAppsConfiguredCmd is `onecli org apps configured`. @@ -58,44 +57,32 @@ func (c *OrgAppsGetCmd) Run(out *output.Writer) error { // OrgAppsConfigureCmd is `onecli org apps configure`. type OrgAppsConfigureCmd struct { - Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` - ClientID string `required:"" name:"client-id" help:"OAuth client ID."` - ClientSecret string `required:"" name:"client-secret" help:"OAuth client secret."` - Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` - DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + Field []string `optional:"" help:"Credential field as key=value (repeatable); names per the app's field definitions."` + ClientID string `optional:"" name:"client-id" help:"OAuth client ID (shorthand for --field clientId=...)."` + ClientSecret string `optional:"" name:"client-secret" help:"OAuth client secret (shorthand for --field clientSecret=...)."` + Json string `optional:"" help:"Raw JSON object of credential fields. Merged first; flags override."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` } func (c *OrgAppsConfigureCmd) Run(out *output.Writer) error { - var input api.ConfigAppInput - if c.Json != "" { - if err := json.Unmarshal([]byte(c.Json), &input); err != nil { - return fmt.Errorf("invalid JSON payload: %w", err) - } - } else { - input = api.ConfigAppInput{ - ClientID: c.ClientID, - ClientSecret: c.ClientSecret, - } - } - if err := validate.ResourceID(c.Provider); err != nil { return fmt.Errorf("invalid provider: %w", err) } + fields, err := buildConfigFields(c.Json, c.Field, c.ClientID, c.ClientSecret) + if err != nil { + return err + } if c.DryRun { - preview := map[string]string{ - "provider": c.Provider, - "clientId": input.ClientID, - "clientSecret": "***", - } - return out.WriteDryRun("Would configure org app", preview) + return out.WriteDryRun("Would configure org app", maskedFieldPreview(c.Provider, fields)) } client, err := newClient() if err != nil { return err } - if err := client.UpsertOrgAppConfig(newContext(), c.Provider, input); err != nil { + if err := client.UpsertOrgAppConfig(newContext(), c.Provider, fields); err != nil { return err } return out.Write(map[string]string{"status": "configured", "provider": c.Provider}) diff --git a/cmd/onecli/org_connections.go b/cmd/onecli/org_connections.go index 6281171..8dd9697 100644 --- a/cmd/onecli/org_connections.go +++ b/cmd/onecli/org_connections.go @@ -10,6 +10,7 @@ import ( // OrgConnectionsCmd is the `onecli org connections` command group. type OrgConnectionsCmd struct { List OrgConnectionsListCmd `cmd:"" help:"List all org-scoped connections."` + Rename OrgConnectionsRenameCmd `cmd:"" help:"Rename an org-scoped connection."` Delete OrgConnectionsDeleteCmd `cmd:"" help:"Delete an org-scoped connection."` } @@ -22,29 +23,16 @@ type OrgConnectionsListCmd struct { } func (c *OrgConnectionsListCmd) Run(out *output.Writer) error { - client, err := newClient() - if err != nil { - return err - } - if c.Provider != "" { if err := validate.ResourceID(c.Provider); err != nil { return fmt.Errorf("invalid provider: %w", err) } - connections, err := client.ListOrgConnectionsByProvider(newContext(), c.Provider) - if err != nil { - return err - } - if c.Max > 0 && len(connections) > c.Max { - connections = connections[:c.Max] - } - if c.Quiet != "" { - return out.WriteQuiet(connections, c.Quiet) - } - return out.WriteFiltered(connections, c.Fields) } - - connections, err := client.ListOrgConnections(newContext()) + client, err := newClient() + if err != nil { + return err + } + connections, err := client.ListOrgConnections(newContext(), c.Provider) if err != nil { return err } @@ -57,6 +45,34 @@ func (c *OrgConnectionsListCmd) Run(out *output.Writer) error { return out.WriteFiltered(connections, c.Fields) } +// OrgConnectionsRenameCmd is `onecli org connections rename`. +type OrgConnectionsRenameCmd struct { + ID string `required:"" help:"ID of the connection to rename."` + Label string `required:"" help:"New display label."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgConnectionsRenameCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid connection ID: %w", err) + } + if c.Label == "" { + return fmt.Errorf("label must not be empty") + } + if c.DryRun { + return out.WriteDryRun("Would rename org connection", map[string]string{"id": c.ID, "label": c.Label}) + } + client, err := newClient() + if err != nil { + return err + } + conn, err := client.RenameOrgConnection(newContext(), c.ID, c.Label) + if err != nil { + return err + } + return out.Write(conn) +} + // OrgConnectionsDeleteCmd is `onecli org connections delete`. type OrgConnectionsDeleteCmd struct { ID string `required:"" help:"ID of the connection to delete."` diff --git a/cmd/onecli/org_rules.go b/cmd/onecli/org_rules.go index ae3c5dc..7b80213 100644 --- a/cmd/onecli/org_rules.go +++ b/cmd/onecli/org_rules.go @@ -17,6 +17,27 @@ type OrgRulesCmd struct { 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."` +} + +// OrgRulesOverlapCmd is `onecli org rules overlap`. +type OrgRulesOverlapCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` +} + +func (c *OrgRulesOverlapCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + count, err := client.GetOrgRuleOverlap(newContext(), c.Provider) + if err != nil { + return err + } + return out.Write(count) } // OrgRulesListCmd is `onecli org rules list`. @@ -69,10 +90,9 @@ func (c *OrgRulesGetCmd) Run(out *output.Writer) error { type OrgRulesCreateCmd struct { Name string `required:"" help:"Display name for the rule."` HostPattern string `required:"" name:"host-pattern" help:"Host pattern to match (e.g. 'api.anthropic.com')."` - Action string `required:"" help:"Action to take: 'block' or 'rate_limit'."` + Action string `required:"" help:"Action to take: 'block', 'rate_limit', 'manual_approval', or 'allow'."` PathPattern string `optional:"" name:"path-pattern" help:"Path pattern to match (e.g. '/v1/*')."` Method string `optional:"" help:"HTTP method to match (GET, POST, PUT, PATCH, DELETE)."` - AgentID string `optional:"" name:"agent-id" help:"Agent ID to scope this rule to. Omit for all agents."` RateLimit *int `optional:"" name:"rate-limit" help:"Max requests per window (required for rate_limit action)."` RateLimitWindow string `optional:"" name:"rate-limit-window" help:"Time window: 'minute', 'hour', or 'day'."` Enabled bool `optional:"" default:"true" help:"Enable rule immediately."` @@ -94,12 +114,16 @@ func (c *OrgRulesCreateCmd) Run(out *output.Writer) error { Method: c.Method, Action: c.Action, Enabled: c.Enabled, - AgentID: c.AgentID, RateLimit: c.RateLimit, RateLimitWindow: c.RateLimitWindow, } } + // The server silently nulls agentId at org scope — reject loudly instead. + if input.AgentID != "" { + return fmt.Errorf("org rules are agent-less: agentId is not supported (use project-scoped 'onecli rules create --agent-id')") + } + if err := validateRuleInput(input.HostPattern, input.PathPattern, input.Method, input.AgentID, input.Action); err != nil { return err } @@ -130,9 +154,8 @@ type OrgRulesUpdateCmd struct { HostPattern string `optional:"" name:"host-pattern" help:"New host pattern."` PathPattern string `optional:"" name:"path-pattern" help:"New path pattern."` Method string `optional:"" help:"New HTTP method."` - Action string `optional:"" help:"New action: 'block' or 'rate_limit'."` + Action string `optional:"" help:"New action: 'block', 'rate_limit', 'manual_approval', or 'allow'."` Enabled *bool `optional:"" help:"Enable or disable the rule."` - AgentID string `optional:"" name:"agent-id" help:"New agent ID scope."` RateLimit *int `optional:"" name:"rate-limit" help:"New max requests per window."` RateLimitWindow string `optional:"" name:"rate-limit-window" help:"New time window."` Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` @@ -168,9 +191,6 @@ func (c *OrgRulesUpdateCmd) Run(out *output.Writer) error { if c.Enabled != nil { input.Enabled = c.Enabled } - if c.AgentID != "" { - input.AgentID = &c.AgentID - } if c.RateLimit != nil { input.RateLimit = c.RateLimit } @@ -179,6 +199,11 @@ func (c *OrgRulesUpdateCmd) Run(out *output.Writer) error { } } + // The server ignores agentId at org scope — reject loudly instead. + if input.AgentID != nil && *input.AgentID != "" { + return fmt.Errorf("org rules are agent-less: agentId is not supported (use project-scoped 'onecli rules update --agent-id')") + } + var hostPattern, pathPattern, method, agentID, action string if input.HostPattern != nil { hostPattern = *input.HostPattern @@ -280,6 +305,9 @@ func (c *OrgRulesPermissionsSetCmd) Run(out *output.Writer) error { if err := json.Unmarshal([]byte(c.Json), &input); err != nil { return fmt.Errorf("invalid JSON payload: %w", err) } + if input.AgentID != "" { + return fmt.Errorf("agent-scoped permissions are project-only: org rules are agent-less (use 'onecli rules permissions set' with --agent-id)") + } if len(input.Changes) == 0 { return fmt.Errorf("'changes' array must contain at least one entry") } diff --git a/cmd/onecli/resources.go b/cmd/onecli/resources.go new file mode 100644 index 0000000..9ce3fcd --- /dev/null +++ b/cmd/onecli/resources.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// VaultsCmd is the `onecli vaults` command group. +type VaultsCmd struct { + List VaultsListCmd `cmd:"" help:"List external vault connections (e.g. 1Password)."` +} + +// VaultsListCmd is `onecli vaults list`. +type VaultsListCmd struct { + 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."` +} + +func (c *VaultsListCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + vaults, err := client.ListVaults(newContext()) + if err != nil { + return err + } + if c.Quiet != "" { + return out.WriteQuiet(vaults, c.Quiet) + } + return out.WriteFiltered(vaults, c.Fields) +} + +// CountsCmd is `onecli counts` — the project's resource totals. +type CountsCmd struct { + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *CountsCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + counts, err := client.GetCounts(newContext()) + if err != nil { + return err + } + return out.WriteFiltered(counts, c.Fields) +} + +// 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."` +} + +// OrgSettingsGetCmd is `onecli org settings get`. +type OrgSettingsGetCmd struct { + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *OrgSettingsGetCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + settings, err := client.GetOrgSettings(newContext()) + if err != nil { + return err + } + return out.WriteFiltered(settings, c.Fields) +} + +// OrgSettingsSetCmd is `onecli org settings set`. +type OrgSettingsSetCmd struct { + PolicyMode string `required:"" name:"policy-mode" help:"Policy mode: 'allow' (default-allow) or 'deny' (lockdown default-deny)."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgSettingsSetCmd) Run(out *output.Writer) error { + if c.PolicyMode != "allow" && c.PolicyMode != "deny" { + return fmt.Errorf("invalid policy-mode %q: must be 'allow' or 'deny'", c.PolicyMode) + } + if c.DryRun { + return out.WriteDryRun("Would update org settings", map[string]string{"policyMode": c.PolicyMode}) + } + client, err := newClient() + if err != nil { + return err + } + settings, err := client.UpdateOrgSettings(newContext(), c.PolicyMode) + if err != nil { + return err + } + return out.Write(settings) +} + +// AppsConfigCmd is the `onecli apps config` command group (project scope). +type AppsConfigCmd struct { + Get AppsConfigGetCmd `cmd:"" help:"Get config status for a provider."` + Toggle AppsConfigToggleCmd `cmd:"" help:"Enable or disable a provider's config."` +} + +// AppsConfigGetCmd is `onecli apps config get`. +type AppsConfigGetCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *AppsConfigGetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + config, err := client.GetAppConfig(newContext(), c.Provider) + if err != nil { + return err + } + return out.WriteFiltered(config, c.Fields) +} + +// AppsConfigToggleCmd is `onecli apps config toggle`. +type AppsConfigToggleCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + Enabled bool `required:"" help:"Set to true to enable, false to disable."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AppsConfigToggleCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would toggle app config", map[string]any{"provider": c.Provider, "enabled": c.Enabled}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.ToggleAppConfig(newContext(), c.Provider, c.Enabled); err != nil { + return err + } + status := "disabled" + if c.Enabled { + status = "enabled" + } + return out.Write(map[string]string{"status": status, "provider": c.Provider}) +} + +// AppsConfiguredCmd is `onecli apps configured` (project scope). +type AppsConfiguredCmd struct { + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *AppsConfiguredCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + providers, err := client.ListConfiguredApps(newContext()) + if err != nil { + return err + } + return out.WriteFiltered(providers, c.Fields) +} + +// AppsEnvDefaultsCmd is `onecli apps env-defaults`. +type AppsEnvDefaultsCmd struct { + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *AppsEnvDefaultsCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + providers, err := client.ListEnvDefaultApps(newContext()) + if err != nil { + return err + } + return out.WriteFiltered(providers, c.Fields) +} diff --git a/cmd/onecli/rules.go b/cmd/onecli/rules.go index 7b015bd..efd3f87 100644 --- a/cmd/onecli/rules.go +++ b/cmd/onecli/rules.go @@ -11,11 +11,154 @@ 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."` + 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."` +} + +// 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)."` +} + +// RulesPermissionsGetCmd is `onecli rules permissions get`. +type RulesPermissionsGetCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + AgentID string `optional:"" name:"agent-id" help:"Show only this agent's override layer."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *RulesPermissionsGetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + states, err := client.GetRulePermissions(newContext(), c.Provider) + if err != nil { + return err + } + if c.AgentID != "" { + if err := validate.ResourceID(c.AgentID); err != nil { + return fmt.Errorf("invalid agent-id: %w", err) + } + layer := states.ByAgent[c.AgentID] + if layer == nil { + layer = map[string]api.PermissionState{} + } + return out.WriteFiltered(map[string]any{ + "agentId": c.AgentID, + "overrides": layer, + "defaults": states.Defaults, + }, c.Fields) + } + return out.WriteFiltered(states, c.Fields) +} + +// RulesPermissionsSetCmd is `onecli rules permissions set`. +type RulesPermissionsSetCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + Tool string `optional:"" help:"Tool ID to change (see 'onecli apps permission-definition'). Alternative to --json."` + Permission string `optional:"" help:"Permission: 'allow', 'manual_approval', 'block', or 'inherit' (agent layer only)."` + AgentID string `optional:"" name:"agent-id" help:"Target one agent's override layer instead of the all-agents defaults."` + Conditions string `optional:"" help:"Content conditions as a JSON array."` + Json string `optional:"" help:"Raw JSON payload with 'changes' array of {toolId, permission}. Overrides --tool/--permission/--conditions."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *RulesPermissionsSetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + + var input api.SetPermissionsInput + if c.Json != "" { + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + } else { + if c.Tool == "" || c.Permission == "" { + return fmt.Errorf("provide --tool and --permission, or a raw --json payload") + } + conditions, err := parseConditions(c.Conditions) + if err != nil { + return err + } + input.Changes = []api.PermissionChange{{ToolID: c.Tool, Permission: c.Permission}} + for _, cond := range conditions { + input.Conditions = append(input.Conditions, cond) + } + } + if c.AgentID != "" { + if err := validate.ResourceID(c.AgentID); err != nil { + return fmt.Errorf("invalid agent-id: %w", err) + } + input.AgentID = c.AgentID + } + + if len(input.Changes) == 0 { + return fmt.Errorf("'changes' array must contain at least one entry") + } + for _, ch := range input.Changes { + if ch.ToolID == "" { + return fmt.Errorf("each change must have a non-empty 'toolId'") + } + if !validPermissionSettings[ch.Permission] { + return fmt.Errorf("invalid permission %q for tool %q: must be 'allow', 'manual_approval', 'block', or 'inherit'", ch.Permission, ch.ToolID) + } + if ch.Permission == "inherit" && input.AgentID == "" { + return fmt.Errorf("'inherit' removes an agent's override and requires --agent-id") + } + } + + if c.DryRun { + return out.WriteDryRun("Would set rule permissions", map[string]any{"provider": c.Provider, "input": input}) + } + + client, err := newClient() + if err != nil { + return err + } + if err := client.SetRulePermissions(newContext(), c.Provider, input); err != nil { + return err + } + return out.Write(map[string]string{"status": "updated", "provider": c.Provider}) +} + +// validPermissionSettings mirrors the server's project permission enum +// (org excludes "inherit" — enforced in the org command). +var validPermissionSettings = map[string]bool{ + "allow": true, + "manual_approval": true, + "block": true, + "inherit": true, +} + +// RulesOverlapCmd is `onecli rules overlap`. +type RulesOverlapCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` +} + +func (c *RulesOverlapCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + count, err := client.GetRuleOverlap(newContext(), c.Provider) + if err != nil { + return err + } + return out.Write(count) } // RulesListCmd is `onecli rules list`. @@ -74,13 +217,14 @@ type RulesCreateCmd struct { Project string `optional:"" short:"p" help:"Project slug."` Name string `required:"" help:"Display name for the rule."` HostPattern string `required:"" name:"host-pattern" help:"Host pattern to match (e.g. 'api.anthropic.com')."` - Action string `required:"" help:"Action to take: 'block' or 'rate_limit'."` + Action string `required:"" help:"Action to take: 'block', 'rate_limit', 'manual_approval', or 'allow'."` PathPattern string `optional:"" name:"path-pattern" help:"Path pattern to match (e.g. '/v1/*')."` Method string `optional:"" help:"HTTP method to match (GET, POST, PUT, PATCH, DELETE)."` AgentID string `optional:"" name:"agent-id" help:"Agent ID to scope this rule to. Omit for all agents."` RateLimit *int `optional:"" name:"rate-limit" help:"Max requests per window (required for rate_limit action)."` RateLimitWindow string `optional:"" name:"rate-limit-window" help:"Time window: 'minute', 'hour', or 'day'."` Enabled bool `optional:"" default:"true" help:"Enable rule immediately."` + Conditions string `optional:"" help:"Content conditions as a JSON array, e.g. '[{\"target\":\"body\",\"operator\":\"contains\",\"value\":\"x\"}]'."` Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` } @@ -92,6 +236,10 @@ func (c *RulesCreateCmd) Run(out *output.Writer) error { return fmt.Errorf("invalid JSON payload: %w", err) } } else { + conditions, err := parseConditions(c.Conditions) + if err != nil { + return err + } input = api.CreateRuleInput{ Name: c.Name, HostPattern: c.HostPattern, @@ -102,6 +250,7 @@ func (c *RulesCreateCmd) Run(out *output.Writer) error { AgentID: c.AgentID, RateLimit: c.RateLimit, RateLimitWindow: c.RateLimitWindow, + Conditions: conditions, } } @@ -139,11 +288,12 @@ type RulesUpdateCmd struct { HostPattern string `optional:"" name:"host-pattern" help:"New host pattern."` PathPattern string `optional:"" name:"path-pattern" help:"New path pattern."` Method string `optional:"" help:"New HTTP method."` - Action string `optional:"" help:"New action: 'block' or 'rate_limit'."` + Action string `optional:"" help:"New action: 'block', 'rate_limit', 'manual_approval', or 'allow'."` Enabled *bool `optional:"" help:"Enable or disable the rule."` AgentID string `optional:"" name:"agent-id" help:"New agent ID scope."` RateLimit *int `optional:"" name:"rate-limit" help:"New max requests per window."` RateLimitWindow string `optional:"" name:"rate-limit-window" help:"New time window."` + Conditions string `optional:"" help:"New content conditions as a JSON array; '[]' clears existing conditions."` Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` } @@ -186,6 +336,13 @@ func (c *RulesUpdateCmd) Run(out *output.Writer) error { if c.RateLimitWindow != "" { input.RateLimitWindow = &c.RateLimitWindow } + if c.Conditions != "" { + conditions, err := parseConditions(c.Conditions) + if err != nil { + return err + } + input.Conditions = &conditions + } } var hostPattern, pathPattern, method, agentID, action string @@ -274,8 +431,28 @@ func validateRuleInput(hostPattern, pathPattern, method, agentID, action string) return fmt.Errorf("invalid agent-id: %w", err) } } - if action != "" && action != "block" && action != "rate_limit" { - return fmt.Errorf("invalid action %q: must be 'block' or 'rate_limit'", action) + if action != "" && !validRuleActions[action] { + return fmt.Errorf("invalid action %q: must be one of 'block', 'rate_limit', 'manual_approval', 'allow'", action) } return nil } + +// validRuleActions mirrors the server's policy-rule action enum. +var validRuleActions = map[string]bool{ + "block": true, + "rate_limit": true, + "manual_approval": true, + "allow": true, +} + +// parseConditions decodes a --conditions JSON array flag into rule conditions. +func parseConditions(raw string) ([]api.RuleCondition, error) { + if raw == "" { + return nil, nil + } + var conditions []api.RuleCondition + if err := json.Unmarshal([]byte(raw), &conditions); err != nil { + return nil, fmt.Errorf(`invalid --conditions (expected JSON array like [{"target":"body","operator":"contains","value":"x"}]): %w`, err) + } + return conditions, nil +} diff --git a/internal/api/agents.go b/internal/api/agents.go index 373c05c..7d9bd02 100644 --- a/internal/api/agents.go +++ b/internal/api/agents.go @@ -51,9 +51,8 @@ type SuccessResponse struct { // ListAgents returns all agents for the authenticated user. // If projectID is non-empty, results are scoped to that project. func (c *Client) ListAgents(ctx context.Context, projectID string) ([]Agent, error) { - path := withProjectQuery("/v1/agents", projectID) var agents []Agent - if err := c.do(ctx, http.MethodGet, path, nil, &agents); err != nil { + if err := c.doProject(ctx, http.MethodGet, "/v1/agents", projectID, nil, &agents); err != nil { return nil, fmt.Errorf("listing agents: %w", err) } return agents, nil @@ -71,9 +70,8 @@ func (c *Client) GetDefaultAgent(ctx context.Context) (*Agent, error) { // CreateAgent creates a new agent. // If projectID is non-empty, the agent is created in that project. func (c *Client) CreateAgent(ctx context.Context, projectID string, input CreateAgentInput) (*Agent, error) { - path := withProjectQuery("/v1/agents", projectID) var agent Agent - if err := c.do(ctx, http.MethodPost, path, input, &agent); err != nil { + if err := c.doProject(ctx, http.MethodPost, "/v1/agents", projectID, input, &agent); err != nil { return nil, fmt.Errorf("creating agent: %w", err) } return &agent, nil @@ -131,3 +129,43 @@ func (c *Client) SetAgentSecretMode(ctx context.Context, id string, input SetSec } return nil } + +// SetDefaultAgent marks an agent as the project default. +func (c *Client) SetDefaultAgent(ctx context.Context, id string) error { + if err := c.do(ctx, http.MethodPost, "/v1/agents/"+id+"/set-default", nil, nil); err != nil { + return fmt.Errorf("setting default agent: %w", err) + } + return nil +} + +// ListGranularAccess returns the per-agent granular-access overview +// (GitHub repos, Dropbox folders) across the project. +func (c *Client) ListGranularAccess(ctx context.Context) (any, error) { + var entries any + if err := c.do(ctx, http.MethodGet, "/v1/agents/granular-access", nil, &entries); err != nil { + return nil, fmt.Errorf("listing granular access: %w", err) + } + return entries, nil +} + +// GetAgentConnections returns an agent's app-connection assignments and +// per-connection granular-access policies. +func (c *Client) GetAgentConnections(ctx context.Context, id string) (any, error) { + var connections any + if err := c.do(ctx, http.MethodGet, "/v1/agents/"+id+"/connections", nil, &connections); err != nil { + return nil, fmt.Errorf("getting agent connections: %w", err) + } + return connections, nil +} + +// SetAgentConnections replaces an agent's app-connection assignments (and +// their granular-access policies). connections is the raw `connections` +// array as the API defines it — agent callers pass it via --json. +func (c *Client) SetAgentConnections(ctx context.Context, id string, connections []any) error { + var resp SuccessResponse + body := map[string]any{"connections": connections} + if err := c.do(ctx, http.MethodPut, "/v1/agents/"+id+"/connections", body, &resp); err != nil { + return fmt.Errorf("setting agent connections: %w", err) + } + return nil +} diff --git a/internal/api/alignment_test.go b/internal/api/alignment_test.go new file mode 100644 index 0000000..87558d7 --- /dev/null +++ b/internal/api/alignment_test.go @@ -0,0 +1,249 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestParseAPIError(t *testing.T) { + tests := []struct { + name string + status int + body string + wantMsg string + wantType string + }{ + { + name: "envelope shape", + status: 401, + body: `{"error":{"message":"X-Project-Id header is required","type":"authentication_error"}}`, + wantMsg: "X-Project-Id header is required", + wantType: "authentication_error", + }, + { + name: "flat shape", + status: 400, + body: `{"error":"Label is required"}`, + wantMsg: "Label is required", + }, + { + name: "unparseable body falls back to status text", + status: 502, + body: `bad gateway`, + wantMsg: "Bad Gateway", + }, + { + name: "empty error falls back to status text", + status: 404, + body: `{"error":""}`, + wantMsg: "Not Found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseAPIError(tt.status, []byte(tt.body)) + if got.Message != tt.wantMsg { + t.Errorf("Message = %q, want %q", got.Message, tt.wantMsg) + } + if got.Type != tt.wantType { + t.Errorf("Type = %q, want %q", got.Type, tt.wantType) + } + if got.StatusCode != tt.status { + t.Errorf("StatusCode = %d, want %d", got.StatusCode, tt.status) + } + }) + } +} + +func TestDoProjectResolvesSlugToHeader(t *testing.T) { + var gotHeader, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/projects": + _, _ = w.Write([]byte(`[{"id":"cproj123","name":"My Project","slug":"my-project","createdAt":"2026-01-01"}]`)) + case "/v1/agents": + gotHeader = r.Header.Get("X-Project-Id") + gotQuery = r.URL.Query().Get("projectId") + _, _ = w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"Not found"}`)) + } + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + if _, err := client.ListAgents(context.Background(), "my-project"); err != nil { + t.Fatalf("ListAgents: %v", err) + } + if gotHeader != "cproj123" { + t.Errorf("X-Project-Id = %q, want resolved id %q", gotHeader, "cproj123") + } + if gotQuery != "my-project" { + t.Errorf("projectId query = %q, want original value %q (legacy compat)", gotQuery, "my-project") + } +} + +func TestDoProjectFallsBackToRawValueWhenProjectsUnavailable(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/projects": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"Not found"}`)) + case "/v1/agents": + gotHeader = r.Header.Get("X-Project-Id") + _, _ = w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + if _, err := client.ListAgents(context.Background(), "cprojraw"); err != nil { + t.Fatalf("ListAgents: %v", err) + } + if gotHeader != "cprojraw" { + t.Errorf("X-Project-Id = %q, want pass-through %q", gotHeader, "cprojraw") + } +} + +func TestListConnectionsNewServer(t *testing.T) { + var gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/connections" { + gotPath = r.URL.Path + gotQuery = r.URL.Query().Get("provider") + _, _ = w.Write([]byte(`[{"id":"c1","provider":"gmail","status":"connected"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"Not found"}`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + conns, err := client.ListConnections(context.Background(), "gmail") + if err != nil { + t.Fatalf("ListConnections: %v", err) + } + if len(conns) != 1 || conns[0].ID != "c1" { + t.Errorf("connections = %+v, want one row c1", conns) + } + if gotPath != "/v1/connections" || gotQuery != "gmail" { + t.Errorf("request = %s?provider=%s, want /v1/connections?provider=gmail", gotPath, gotQuery) + } +} + +func TestListConnectionsLegacyFallback(t *testing.T) { + var gotLegacyPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/connections": + // Pre-promotion server: route doesn't exist. + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"message":"Not found","type":"not_found_error"}}`)) + case "/v1/apps/connections/gmail": + gotLegacyPath = r.URL.Path + _, _ = w.Write([]byte(`{"connections":[{"id":"c1","provider":"gmail","status":"connected"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + conns, err := client.ListConnections(context.Background(), "gmail") + if err != nil { + t.Fatalf("ListConnections: %v", err) + } + if len(conns) != 1 || conns[0].ID != "c1" { + t.Errorf("connections = %+v, want one row c1 from legacy envelope", conns) + } + if gotLegacyPath != "/v1/apps/connections/gmail" { + t.Errorf("legacy path = %q, want /v1/apps/connections/gmail", gotLegacyPath) + } +} + +func TestGetAppPermissionsDecodesLayeredShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/org/rules/permissions/gmail" { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{ + "defaults": {"send_message": {"permission": "block", "conditions": []}}, + "byAgent": {"agt1": {"get_message": {"permission": "allow", "conditions": []}}} + }`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + states, err := client.GetAppPermissions(context.Background(), "gmail") + if err != nil { + t.Fatalf("GetAppPermissions: %v", err) + } + if got := states.Defaults["send_message"].Permission; got != "block" { + t.Errorf("Defaults[send_message] = %q, want block", got) + } + if got := states.ByAgent["agt1"]["get_message"].Permission; got != "allow" { + t.Errorf("ByAgent[agt1][get_message] = %q, want allow", got) + } +} + +func TestConfigureAppSendsArbitraryFields(t *testing.T) { + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"success":true}`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + fields := ConfigFields{"appId": "123", "appSlug": "my-app", "privateKey": "pem"} + if err := client.ConfigureApp(context.Background(), "github-app", fields); err != nil { + t.Fatalf("ConfigureApp: %v", err) + } + if gotBody["appId"] != "123" || gotBody["appSlug"] != "my-app" || gotBody["privateKey"] != "pem" { + t.Errorf("body = %v, want all three github-app fields passed through", gotBody) + } +} + +func TestUpdateRuleRefetchesAfterSuccess(t *testing.T) { + var patched bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/v1/rules/r1": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["conditions"]; !ok { + t.Errorf("PATCH body missing conditions: %v", body) + } + patched = true + _, _ = w.Write([]byte(`{"success":true}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/rules/r1": + _, _ = w.Write([]byte(`{"id":"r1","name":"Updated","action":"manual_approval","enabled":true}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + conditions := []RuleCondition{{Target: "body", Operator: "contains", Value: "x"}} + rule, err := client.UpdateRule(context.Background(), "r1", UpdateRuleInput{Conditions: &conditions}) + if err != nil { + t.Fatalf("UpdateRule: %v", err) + } + if !patched { + t.Fatal("PATCH was never sent") + } + if rule.Name != "Updated" || rule.Action != "manual_approval" { + t.Errorf("rule = %+v, want the re-fetched rule", rule) + } +} diff --git a/internal/api/apps.go b/internal/api/apps.go index 21da3e9..61aba80 100644 --- a/internal/api/apps.go +++ b/internal/api/apps.go @@ -8,14 +8,18 @@ import ( // App represents an app from the /v1/apps endpoints. type App struct { - ID string `json:"id"` - Name string `json:"name"` - Available bool `json:"available"` - ConnectionType string `json:"connectionType"` - Configurable bool `json:"configurable"` - Config *AppConfig `json:"config"` - Connection *AppConnection `json:"connection"` - Hint string `json:"hint,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Available bool `json:"available"` + ConnectionType string `json:"connectionType"` + Configurable bool `json:"configurable"` + Config *AppConfig `json:"config"` + // Deprecated: first connection only — misleading for multi-account + // providers. Prefer Connections. + Connection *AppConnection `json:"connection"` + Connections []AppConnection `json:"connections,omitempty"` + CredentialStubs []any `json:"credentialStubs,omitempty"` + Hint string `json:"hint,omitempty"` } // AppConfig is the BYOC credential configuration status. @@ -31,14 +35,15 @@ type AppConnection struct { Label string `json:"label,omitempty"` Status string `json:"status"` Scopes []string `json:"scopes"` + Scope string `json:"scope,omitempty"` ConnectedAt string `json:"connectedAt"` } -// ConfigAppInput is the request body for saving BYOC credentials. -type ConfigAppInput struct { - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` -} +// ConfigFields holds BYOC credential fields for a provider. Keys are +// validated server-side against the app's own configurable field definitions +// (e.g. clientId/clientSecret for OAuth apps; appId/appSlug/privateKey for +// github-app) — unknown keys are stripped by the server. +type ConfigFields map[string]string // ListApps returns all apps with their config and connection status. func (c *Client) ListApps(ctx context.Context) ([]App, error) { @@ -58,40 +63,50 @@ func (c *Client) GetApp(ctx context.Context, provider string) (*App, error) { return &app, nil } -// ListConnections returns all app connections for the current project. -func (c *Client) ListConnections(ctx context.Context) ([]AppConnection, error) { - var resp struct { - Connections []AppConnection `json:"connections"` - } - if err := c.do(ctx, http.MethodGet, "/v1/apps/connections", nil, &resp); err != nil { - return nil, fmt.Errorf("listing connections: %w", err) - } - return resp.Connections, nil +// Project connection operations live in connections.go (top-level +// /v1/connections resource with a legacy-path fallback). + +// AppTool is one operation in an app's permission catalog. +type AppTool struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + HostPattern string `json:"hostPattern"` + PathPattern string `json:"pathPattern"` + AliasPatterns []string `json:"aliasPatterns,omitempty"` + Method string `json:"method,omitempty"` + Methods []string `json:"methods,omitempty"` } -// ListConnectionsByProvider returns app connections for a specific provider. -func (c *Client) ListConnectionsByProvider(ctx context.Context, provider string) ([]AppConnection, error) { - var resp struct { - Connections []AppConnection `json:"connections"` - } - if err := c.do(ctx, http.MethodGet, "/v1/apps/connections/"+provider, nil, &resp); err != nil { - return nil, fmt.Errorf("listing connections for %s: %w", provider, err) - } - return resp.Connections, nil +// AppToolGroup groups an app's tools by read/write category, optionally with +// a wildcard tool covering the whole group. +type AppToolGroup struct { + Category string `json:"category"` + Tools []AppTool `json:"tools"` + Wildcard *AppTool `json:"wildcard,omitempty"` +} + +// PermissionDefinition is an app's static tool catalog — the toolIds that +// rules permissions get/set operate on. +type PermissionDefinition struct { + Provider string `json:"provider"` + Groups []AppToolGroup `json:"groups"` } -// DisconnectApp removes an app connection by ID. -func (c *Client) DisconnectApp(ctx context.Context, connectionID string) error { - if err := c.do(ctx, http.MethodDelete, "/v1/apps/connections/"+connectionID, nil, nil); err != nil { - return fmt.Errorf("disconnecting app: %w", err) +// GetPermissionDefinition returns an app's permission catalog. Works without +// a project context (the catalog is global static data). +func (c *Client) GetPermissionDefinition(ctx context.Context, provider string) (*PermissionDefinition, error) { + var def PermissionDefinition + if err := c.do(ctx, http.MethodGet, "/v1/apps/"+provider+"/permission-definition", nil, &def); err != nil { + return nil, fmt.Errorf("getting permission definition: %w", err) } - return nil + return &def, nil } // ConfigureApp saves BYOC credentials for a provider. -func (c *Client) ConfigureApp(ctx context.Context, provider string, input ConfigAppInput) error { +func (c *Client) ConfigureApp(ctx context.Context, provider string, fields ConfigFields) error { var resp SuccessResponse - if err := c.do(ctx, http.MethodPost, "/v1/apps/"+provider+"/config", input, &resp); err != nil { + if err := c.do(ctx, http.MethodPost, "/v1/apps/"+provider+"/config", fields, &resp); err != nil { return fmt.Errorf("configuring app: %w", err) } return nil diff --git a/internal/api/blocklist.go b/internal/api/blocklist.go new file mode 100644 index 0000000..7c63d3c --- /dev/null +++ b/internal/api/blocklist.go @@ -0,0 +1,62 @@ +package api + +import ( + "context" + "fmt" + "net/http" +) + +// blocklistBase returns the blocklist path prefix for a scope. +func blocklistBase(org bool, provider string) string { + if org { + return "/v1/org/apps/" + provider + "/blocklist" + } + return "/v1/apps/" + provider + "/blocklist" +} + +// GetBlocklist returns the blocklist state for a provider (default hosts and +// custom rules with their enabled state). +func (c *Client) GetBlocklist(ctx context.Context, provider string, org bool) (any, error) { + var states any + if err := c.do(ctx, http.MethodGet, blocklistBase(org, provider), nil, &states); err != nil { + return nil, fmt.Errorf("getting blocklist: %w", err) + } + return states, nil +} + +// ActivateBlocklistHost enables one of the app's predefined blocklist hosts. +func (c *Client) ActivateBlocklistHost(ctx context.Context, provider, hostID string, org bool) (any, error) { + var result any + body := map[string]string{"hostId": hostID} + if err := c.do(ctx, http.MethodPost, blocklistBase(org, provider), body, &result); err != nil { + return nil, fmt.Errorf("activating blocklist host: %w", err) + } + return result, nil +} + +// AddBlocklistRule adds a custom blocklist rule for a provider. +func (c *Client) AddBlocklistRule(ctx context.Context, provider, name, hostPattern string, org bool) (any, error) { + var result any + body := map[string]string{"name": name, "hostPattern": hostPattern} + if err := c.do(ctx, http.MethodPost, blocklistBase(org, provider), body, &result); err != nil { + return nil, fmt.Errorf("adding blocklist rule: %w", err) + } + return result, nil +} + +// ToggleBlocklistRule enables or disables a blocklist rule. +func (c *Client) ToggleBlocklistRule(ctx context.Context, provider, ruleID string, enabled, org bool) error { + body := map[string]bool{"enabled": enabled} + if err := c.do(ctx, http.MethodPatch, blocklistBase(org, provider)+"/"+ruleID, body, nil); err != nil { + return fmt.Errorf("toggling blocklist rule: %w", err) + } + return nil +} + +// RemoveBlocklistRule removes a blocklist rule. +func (c *Client) RemoveBlocklistRule(ctx context.Context, provider, ruleID string, org bool) error { + if err := c.do(ctx, http.MethodDelete, blocklistBase(org, provider)+"/"+ruleID, nil, nil); err != nil { + return fmt.Errorf("removing blocklist rule: %w", err) + } + return nil +} diff --git a/internal/api/client.go b/internal/api/client.go index 56b62a6..9db3df5 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -25,6 +25,16 @@ type Client struct { httpClient *http.Client prefix string // resolved API prefix: "/v1" or "/api" prefixOnce sync.Once // ensures prefix detection runs once + + // Project slug/id → resolved project id, cached per process. /v1 servers + // scope requests via the X-Project-Id header (ids, not slugs); the legacy + // ?projectId= query is kept alongside for old /api servers. + projectIDs map[string]string + projectIDsMu sync.Mutex + + connBase string // resolved project-connections base path + connEnvelope bool // legacy base wraps list responses in {connections} + connOnce sync.Once // ensures the connections probe runs once } // New creates an API client. @@ -86,12 +96,40 @@ func withProjectQuery(basePath, projectID string) string { type APIError struct { StatusCode int Message string + Type string // server error type (e.g. "authentication_error"), when present } func (e *APIError) Error() string { return e.Message } +// parseAPIError extracts the server's message from either error shape: the +// envelope {"error":{"message","type"}} (auth failures, ServiceErrors, 5xx) +// or the flat {"error":"..."} used by route-level validation, falling back +// to the HTTP status text. +func parseAPIError(status int, body []byte) *APIError { + var envelope struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if json.Unmarshal(body, &envelope) == nil && envelope.Error.Message != "" { + return &APIError{ + StatusCode: status, + Message: envelope.Error.Message, + Type: envelope.Error.Type, + } + } + var flat struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &flat) == nil && flat.Error != "" { + return &APIError{StatusCode: status, Message: flat.Error} + } + return &APIError{StatusCode: status, Message: http.StatusText(status)} +} + // networkError translates raw Go network errors into user-friendly messages. func (c *Client) networkError(err error) error { host := c.baseURL @@ -143,7 +181,83 @@ func (c *Client) applyPrefix(path string) string { // do executes an HTTP request and decodes the JSON response. // For 204 responses, result should be nil. func (c *Client) do(ctx context.Context, method, path string, body any, result any) error { + return c.doProject(ctx, method, path, "", body, result) +} + +// resolveProjectID maps a project slug or id to the project id via +// GET /v1/projects, cached per value for the process. Unresolvable values +// (legacy servers without /v1/projects, unknown slugs, transient failures) +// pass through as-is so the server rejects a bad selection loudly instead of +// it being dropped. The lookup runs outside the cache lock — a duplicate +// concurrent fetch is harmless. +func (c *Client) resolveProjectID(ctx context.Context, project string) string { + c.projectIDsMu.Lock() + if c.projectIDs == nil { + c.projectIDs = map[string]string{} + } + if id, ok := c.projectIDs[project]; ok { + c.projectIDsMu.Unlock() + return id + } + c.projectIDsMu.Unlock() + + id := project + var projects []Project + if err := c.do(ctx, http.MethodGet, "/v1/projects", nil, &projects); err == nil { + for _, p := range projects { + if p.ID == project || p.Slug == project { + id = p.ID + break + } + } + } + + c.projectIDsMu.Lock() + c.projectIDs[project] = id + c.projectIDsMu.Unlock() + return id +} + +// resolveConnectionsBase probes once for the top-level /v1/connections +// resource. Older servers (pre connections promotion) 404 it and keep the +// legacy /v1/apps/connections paths, whose list responses are wrapped in a +// {connections} envelope. Mirrors the /v1-vs-/api prefix probe. +func (c *Client) resolveConnectionsBase(ctx context.Context) (base string, envelope bool) { + c.connOnce.Do(func() { + c.connBase, c.connEnvelope = "/v1/connections", false + c.resolvePrefix(ctx) + req, err := http.NewRequestWithContext( + ctx, http.MethodGet, c.baseURL+c.applyPrefix("/v1/connections"), nil, + ) + if err != nil { + return + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + resp, err := c.httpClient.Do(req) + if resp != nil { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + if err == nil && resp.StatusCode == http.StatusNotFound { + c.connBase, c.connEnvelope = "/v1/apps/connections", true + } + }) + return c.connBase, c.connEnvelope +} + +// doProject executes a request scoped to an explicit project (slug or id). +// The resolved project id is sent as X-Project-Id — how /v1 servers scope +// requests — and the legacy ?projectId= query is kept alongside for old +// /api servers. An empty project means "the API key's own project". +func (c *Client) doProject(ctx context.Context, method, path, project string, body any, result any) error { c.resolvePrefix(ctx) + var projectHeader string + if project != "" { + path = withProjectQuery(path, project) + projectHeader = c.resolveProjectID(ctx, project) + } path = c.applyPrefix(path) var bodyReader io.Reader if body != nil { @@ -162,6 +276,9 @@ func (c *Client) do(ctx context.Context, method, path string, body any, result a if c.apiKey != "" { req.Header.Set("Authorization", "Bearer "+c.apiKey) } + if projectHeader != "" { + req.Header.Set("X-Project-Id", projectHeader) + } if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -182,13 +299,7 @@ func (c *Client) do(ctx context.Context, method, path string, body any, result a } if resp.StatusCode >= 400 { - var errResp struct { - Error string `json:"error"` - } - if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" { - return &APIError{StatusCode: resp.StatusCode, Message: errResp.Error} - } - return &APIError{StatusCode: resp.StatusCode, Message: http.StatusText(resp.StatusCode)} + return parseAPIError(resp.StatusCode, respBody) } if result != nil { diff --git a/internal/api/connections.go b/internal/api/connections.go new file mode 100644 index 0000000..a89ee37 --- /dev/null +++ b/internal/api/connections.go @@ -0,0 +1,59 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// ListConnections returns the project's app connections, optionally filtered +// by provider. Prefers the top-level /v1/connections resource (bare array, +// ?provider= filter); falls back once per process to the legacy +// /v1/apps/connections paths ({connections} envelope, filter-in-path) on +// older servers. +func (c *Client) ListConnections(ctx context.Context, provider string) ([]AppConnection, error) { + base, envelope := c.resolveConnectionsBase(ctx) + if envelope { + path := base + if provider != "" { + path += "/" + url.PathEscape(provider) + } + var resp struct { + Connections []AppConnection `json:"connections"` + } + if err := c.do(ctx, http.MethodGet, path, nil, &resp); err != nil { + return nil, fmt.Errorf("listing connections: %w", err) + } + return resp.Connections, nil + } + path := base + if provider != "" { + path += "?provider=" + url.QueryEscape(provider) + } + var connections []AppConnection + if err := c.do(ctx, http.MethodGet, path, nil, &connections); err != nil { + return nil, fmt.Errorf("listing connections: %w", err) + } + return connections, nil +} + +// DisconnectApp removes an app connection by ID. +func (c *Client) DisconnectApp(ctx context.Context, connectionID string) error { + base, _ := c.resolveConnectionsBase(ctx) + if err := c.do(ctx, http.MethodDelete, base+"/"+connectionID, nil, nil); err != nil { + return fmt.Errorf("disconnecting app: %w", err) + } + return nil +} + +// RenameConnection sets the display label of an app connection. +func (c *Client) RenameConnection(ctx context.Context, connectionID, label string) (*AppConnection, error) { + base, _ := c.resolveConnectionsBase(ctx) + var conn AppConnection + body := map[string]string{"label": label} + if err := c.do(ctx, http.MethodPatch, base+"/"+connectionID, body, &conn); err != nil { + return nil, fmt.Errorf("renaming connection: %w", err) + } + return &conn, nil +} diff --git a/internal/api/org_apps.go b/internal/api/org_apps.go index f2d6897..4568a9a 100644 --- a/internal/api/org_apps.go +++ b/internal/api/org_apps.go @@ -8,8 +8,9 @@ import ( // OrgAppConfig is the config status for an org-scoped app provider. type OrgAppConfig struct { - HasCredentials bool `json:"hasCredentials"` - Enabled bool `json:"enabled"` + Settings map[string]string `json:"settings,omitempty"` + HasCredentials bool `json:"hasCredentials"` + Enabled bool `json:"enabled"` } // ToggleInput is the request body for toggling app config. @@ -36,9 +37,9 @@ func (c *Client) GetOrgAppConfig(ctx context.Context, provider string) (*OrgAppC } // UpsertOrgAppConfig saves BYOC credentials for a provider at the org level. -func (c *Client) UpsertOrgAppConfig(ctx context.Context, provider string, input ConfigAppInput) error { +func (c *Client) UpsertOrgAppConfig(ctx context.Context, provider string, fields ConfigFields) error { var resp SuccessResponse - if err := c.do(ctx, http.MethodPost, "/v1/org/apps/"+provider+"/config", input, &resp); err != nil { + if err := c.do(ctx, http.MethodPost, "/v1/org/apps/"+provider+"/config", fields, &resp); err != nil { return fmt.Errorf("configuring org app: %w", err) } return nil diff --git a/internal/api/org_connections.go b/internal/api/org_connections.go index d2df173..b286dba 100644 --- a/internal/api/org_connections.go +++ b/internal/api/org_connections.go @@ -4,38 +4,51 @@ import ( "context" "fmt" "net/http" + "net/url" ) // Connection represents an OAuth connection returned by the API. type Connection struct { - ID string `json:"id"` - Provider string `json:"provider"` - Status string `json:"status"` - ConnectedAt string `json:"connectedAt"` + ID string `json:"id"` + Provider string `json:"provider"` + Label string `json:"label,omitempty"` + Status string `json:"status"` + Scopes []string `json:"scopes,omitempty"` + Scope string `json:"scope,omitempty"` + ConnectedAt string `json:"connectedAt"` } -// ListOrgConnections returns all connections for the organization. -func (c *Client) ListOrgConnections(ctx context.Context) ([]Connection, error) { - var connections []Connection - if err := c.do(ctx, http.MethodGet, "/v1/org/apps/connections", nil, &connections); err != nil { - return nil, fmt.Errorf("listing org connections: %w", err) +// ListOrgConnections returns the organization's connections, optionally +// filtered by provider. /v1/org/connections is served by every deployed +// server (it predates and now supersedes the /v1/org/apps/connections +// alias); provider filtering uses the path form because older servers drop +// query params on this route. +func (c *Client) ListOrgConnections(ctx context.Context, provider string) ([]Connection, error) { + path := "/v1/org/connections" + if provider != "" { + path += "/" + url.PathEscape(provider) } - return connections, nil -} - -// ListOrgConnectionsByProvider returns connections for a specific provider. -func (c *Client) ListOrgConnectionsByProvider(ctx context.Context, provider string) ([]Connection, error) { var connections []Connection - if err := c.do(ctx, http.MethodGet, "/v1/org/apps/connections/"+provider, nil, &connections); err != nil { - return nil, fmt.Errorf("listing org connections for provider: %w", err) + if err := c.do(ctx, http.MethodGet, path, nil, &connections); err != nil { + return nil, fmt.Errorf("listing org connections: %w", err) } return connections, nil } // DeleteOrgConnection removes an org-scoped connection by ID. func (c *Client) DeleteOrgConnection(ctx context.Context, connectionID string) error { - if err := c.do(ctx, http.MethodDelete, "/v1/org/apps/connections/"+connectionID, nil, nil); err != nil { + if err := c.do(ctx, http.MethodDelete, "/v1/org/connections/"+connectionID, nil, nil); err != nil { return fmt.Errorf("deleting org connection: %w", err) } return nil } + +// RenameOrgConnection sets the display label of an org-scoped connection. +func (c *Client) RenameOrgConnection(ctx context.Context, connectionID, label string) (*Connection, error) { + var conn Connection + body := map[string]string{"label": label} + if err := c.do(ctx, http.MethodPatch, "/v1/org/connections/"+connectionID, body, &conn); err != nil { + return nil, fmt.Errorf("renaming org connection: %w", err) + } + return &conn, nil +} diff --git a/internal/api/org_rules.go b/internal/api/org_rules.go index 7b86d2f..5852cb5 100644 --- a/internal/api/org_rules.go +++ b/internal/api/org_rules.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "fmt" "net/http" ) @@ -12,6 +13,15 @@ type PermissionState struct { Conditions []any `json:"conditions"` } +// AppPermissionStates is the layered permission response: Defaults holds the +// all-agents states (toolId → state); ByAgent holds per-agent override +// layers (agentId → toolId → state; always empty at org scope, where rules +// are agent-less). +type AppPermissionStates struct { + Defaults map[string]PermissionState `json:"defaults"` + ByAgent map[string]map[string]PermissionState `json:"byAgent"` +} + // PermissionChange is a single tool permission change for SetAppPermissions. type PermissionChange struct { ToolID string `json:"toolId"` @@ -19,9 +29,12 @@ type PermissionChange struct { } // SetPermissionsInput is the request body for setting app permissions. +// AgentID targets one agent's override layer (project scope only — org +// permissions are agent-less and the org endpoint rejects it). type SetPermissionsInput struct { Changes []PermissionChange `json:"changes"` Conditions []any `json:"conditions,omitempty"` + AgentID string `json:"agentId,omitempty"` } // ListOrgRules returns all policy rules scoped to the organization. @@ -51,13 +64,13 @@ func (c *Client) CreateOrgRule(ctx context.Context, input CreateRuleInput) (*Rul return &rule, nil } -// UpdateOrgRule updates an org-scoped policy rule. +// UpdateOrgRule updates an org-scoped policy rule. The PATCH endpoint +// returns {success:true}, so the updated rule is re-fetched for output. func (c *Client) UpdateOrgRule(ctx context.Context, id string, input UpdateRuleInput) (*Rule, error) { - var rule Rule - if err := c.do(ctx, http.MethodPatch, "/v1/org/rules/"+id, input, &rule); err != nil { + if err := c.do(ctx, http.MethodPatch, "/v1/org/rules/"+id, input, nil); err != nil { return nil, fmt.Errorf("updating org rule: %w", err) } - return &rule, nil + return c.GetOrgRule(ctx, id) } // DeleteOrgRule deletes an org-scoped policy rule. @@ -68,13 +81,27 @@ func (c *Client) DeleteOrgRule(ctx context.Context, id string) error { return nil } -// GetAppPermissions returns the tool-level permission states for a provider. -func (c *Client) GetAppPermissions(ctx context.Context, provider string) (map[string]PermissionState, error) { - var states map[string]PermissionState - if err := c.do(ctx, http.MethodGet, "/v1/org/rules/permissions/"+provider, nil, &states); err != nil { +// GetAppPermissions returns the layered tool-level permission states for a +// provider at the organization scope. Servers predating the layered shape +// returned a flat {toolId: state} map — detected and lifted into Defaults so +// the output is never silently empty against an older deployment. +func (c *Client) GetAppPermissions(ctx context.Context, provider string) (*AppPermissionStates, error) { + var raw json.RawMessage + if err := c.do(ctx, http.MethodGet, "/v1/org/rules/permissions/"+provider, nil, &raw); err != nil { return nil, fmt.Errorf("getting app permissions: %w", err) } - return states, nil + var states AppPermissionStates + if err := json.Unmarshal(raw, &states); err != nil { + return nil, fmt.Errorf("decoding app permissions: %w", err) + } + if states.Defaults == nil && states.ByAgent == nil { + var flat map[string]PermissionState + if err := json.Unmarshal(raw, &flat); err == nil && len(flat) > 0 { + states.Defaults = flat + states.ByAgent = map[string]map[string]PermissionState{} + } + } + return &states, nil } // SetAppPermissions updates tool-level permissions for a provider. @@ -85,3 +112,12 @@ func (c *Client) SetAppPermissions(ctx context.Context, provider string, input S } return nil } + +// GetOrgRuleOverlap counts custom rules overlapping an app's hosts (org). +func (c *Client) GetOrgRuleOverlap(ctx context.Context, provider string) (*OverlapCount, error) { + var count OverlapCount + if err := c.do(ctx, http.MethodGet, "/v1/org/rules/overlap/"+provider, nil, &count); err != nil { + return nil, fmt.Errorf("getting org rule overlap: %w", err) + } + return &count, nil +} diff --git a/internal/api/projects.go b/internal/api/projects.go index b53df18..463a9ce 100644 --- a/internal/api/projects.go +++ b/internal/api/projects.go @@ -6,11 +6,14 @@ import ( "net/http" ) -// Project represents a project returned by the API. +// Project represents a project returned by the API. APIKey is only present +// on the create response — surfaced because it is not retrievable afterwards +// via the CLI. type Project struct { ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` + APIKey string `json:"apiKey,omitempty"` CreatedAt string `json:"createdAt"` } diff --git a/internal/api/resources.go b/internal/api/resources.go new file mode 100644 index 0000000..e9ca296 --- /dev/null +++ b/internal/api/resources.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + "fmt" + "net/http" +) + +// VaultConnection is an external vault (e.g. 1Password) pairing. +type VaultConnection struct { + ID string `json:"id"` + Provider string `json:"provider"` + Status string `json:"status"` + Name *string `json:"name"` + LastConnectedAt *string `json:"lastConnectedAt"` +} + +// ListVaults returns the project's external vault connections. +func (c *Client) ListVaults(ctx context.Context) ([]VaultConnection, error) { + var vaults []VaultConnection + if err := c.do(ctx, http.MethodGet, "/v1/vaults", nil, &vaults); err != nil { + return nil, fmt.Errorf("listing vaults: %w", err) + } + return vaults, nil +} + +// ResourceCounts is the project's resource totals. +type ResourceCounts struct { + Agents int `json:"agents"` + Apps int `json:"apps"` + Llms int `json:"llms"` + Secrets int `json:"secrets"` +} + +// GetCounts returns the project's resource counts. +func (c *Client) GetCounts(ctx context.Context) (*ResourceCounts, error) { + var counts ResourceCounts + if err := c.do(ctx, http.MethodGet, "/v1/counts", nil, &counts); err != nil { + return nil, fmt.Errorf("getting counts: %w", err) + } + return &counts, nil +} + +// OrgSettings holds organization-level settings. +type OrgSettings struct { + PolicyMode string `json:"policyMode"` +} + +// GetOrgSettings returns the organization's settings. +func (c *Client) GetOrgSettings(ctx context.Context) (*OrgSettings, error) { + var settings OrgSettings + if err := c.do(ctx, http.MethodGet, "/v1/org/settings", nil, &settings); err != nil { + return nil, fmt.Errorf("getting org settings: %w", err) + } + return &settings, nil +} + +// UpdateOrgSettings updates the organization's policy mode +// ("allow" = YOLO default-allow, "deny" = lockdown default-deny). +func (c *Client) UpdateOrgSettings(ctx context.Context, policyMode string) (*OrgSettings, error) { + var settings OrgSettings + body := map[string]string{"policyMode": policyMode} + if err := c.do(ctx, http.MethodPatch, "/v1/org/settings", body, &settings); err != nil { + return nil, fmt.Errorf("updating org settings: %w", err) + } + return &settings, nil +} + +// AppConfigStatus is a provider's BYOC config status (project scope). +type AppConfigStatus struct { + Settings map[string]string `json:"settings,omitempty"` + HasCredentials bool `json:"hasCredentials"` + Enabled bool `json:"enabled"` +} + +// GetAppConfig returns the project-scope config status for a provider. +func (c *Client) GetAppConfig(ctx context.Context, provider string) (*AppConfigStatus, error) { + var config AppConfigStatus + if err := c.do(ctx, http.MethodGet, "/v1/apps/"+provider+"/config", nil, &config); err != nil { + return nil, fmt.Errorf("getting app config: %w", err) + } + return &config, nil +} + +// ToggleAppConfig enables or disables a provider's config (project scope). +func (c *Client) ToggleAppConfig(ctx context.Context, provider string, enabled bool) error { + var resp SuccessResponse + body := ToggleInput{Enabled: enabled} + if err := c.do(ctx, http.MethodPatch, "/v1/apps/"+provider+"/config/toggle", body, &resp); err != nil { + return fmt.Errorf("toggling app config: %w", err) + } + return nil +} + +// ListConfiguredApps returns providers with an enabled config (project). +func (c *Client) ListConfiguredApps(ctx context.Context) ([]string, error) { + var providers []string + if err := c.do(ctx, http.MethodGet, "/v1/apps/configured", nil, &providers); err != nil { + return nil, fmt.Errorf("listing configured apps: %w", err) + } + return providers, nil +} + +// ListEnvDefaultApps returns providers with platform default credentials. +func (c *Client) ListEnvDefaultApps(ctx context.Context) ([]string, error) { + var providers []string + if err := c.do(ctx, http.MethodGet, "/v1/apps/env-defaults", nil, &providers); err != nil { + return nil, fmt.Errorf("listing env-default apps: %w", err) + } + return providers, nil +} diff --git a/internal/api/rules.go b/internal/api/rules.go index 29a74e4..0f623f8 100644 --- a/internal/api/rules.go +++ b/internal/api/rules.go @@ -8,51 +8,66 @@ import ( // Rule represents a policy rule returned by the API. type Rule struct { - ID string `json:"id"` - Name string `json:"name"` - HostPattern string `json:"hostPattern"` - PathPattern *string `json:"pathPattern"` - Method *string `json:"method"` - Action string `json:"action"` - Enabled bool `json:"enabled"` - AgentID *string `json:"agentId"` - RateLimit *int `json:"rateLimit"` - RateLimitWindow *string `json:"rateLimitWindow"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + Name string `json:"name"` + HostPattern string `json:"hostPattern"` + PathPattern *string `json:"pathPattern"` + Method *string `json:"method"` + Action string `json:"action"` + Enabled bool `json:"enabled"` + AgentID *string `json:"agentId"` + RateLimit *int `json:"rateLimit"` + RateLimitWindow *string `json:"rateLimitWindow"` + Scope *string `json:"scope,omitempty"` + Conditions []RuleCondition `json:"conditions,omitempty"` + Metadata any `json:"metadata,omitempty"` + CreatedAt string `json:"createdAt"` +} + +// RuleCondition matches request content (e.g. body contains a value). +// Key optionally narrows the match to a specific field within the target. +type RuleCondition struct { + Target string `json:"target"` + Operator string `json:"operator"` + Value string `json:"value"` + Key string `json:"key,omitempty"` } // CreateRuleInput is the request body for creating a rule. type CreateRuleInput struct { - Name string `json:"name"` - HostPattern string `json:"hostPattern"` - PathPattern string `json:"pathPattern,omitempty"` - Method string `json:"method,omitempty"` - Action string `json:"action"` - Enabled bool `json:"enabled"` - AgentID string `json:"agentId,omitempty"` - RateLimit *int `json:"rateLimit,omitempty"` - RateLimitWindow string `json:"rateLimitWindow,omitempty"` + Name string `json:"name"` + HostPattern string `json:"hostPattern"` + PathPattern string `json:"pathPattern,omitempty"` + Method string `json:"method,omitempty"` + Action string `json:"action"` + Enabled bool `json:"enabled"` + AgentID string `json:"agentId,omitempty"` + RateLimit *int `json:"rateLimit,omitempty"` + RateLimitWindow string `json:"rateLimitWindow,omitempty"` + Conditions []RuleCondition `json:"conditions,omitempty"` } // UpdateRuleInput is the request body for updating a rule. +// Conditions uses a pointer so an explicit empty slice clears existing +// conditions while nil omits the field entirely. type UpdateRuleInput struct { - Name *string `json:"name,omitempty"` - HostPattern *string `json:"hostPattern,omitempty"` - PathPattern *string `json:"pathPattern,omitempty"` - Method *string `json:"method,omitempty"` - Action *string `json:"action,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - AgentID *string `json:"agentId,omitempty"` - RateLimit *int `json:"rateLimit,omitempty"` - RateLimitWindow *string `json:"rateLimitWindow,omitempty"` + Name *string `json:"name,omitempty"` + HostPattern *string `json:"hostPattern,omitempty"` + PathPattern *string `json:"pathPattern,omitempty"` + Method *string `json:"method,omitempty"` + Action *string `json:"action,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + AgentID *string `json:"agentId,omitempty"` + RateLimit *int `json:"rateLimit,omitempty"` + RateLimitWindow *string `json:"rateLimitWindow,omitempty"` + Conditions *[]RuleCondition `json:"conditions,omitempty"` } // ListRules returns all policy rules for the authenticated user. // If projectID is non-empty, results are scoped to that project. func (c *Client) ListRules(ctx context.Context, projectID string) ([]Rule, error) { - path := withProjectQuery("/v1/rules", projectID) var rules []Rule - if err := c.do(ctx, http.MethodGet, path, nil, &rules); err != nil { + if err := c.doProject(ctx, http.MethodGet, "/v1/rules", projectID, nil, &rules); err != nil { return nil, fmt.Errorf("listing rules: %w", err) } return rules, nil @@ -70,21 +85,20 @@ func (c *Client) GetRule(ctx context.Context, id string) (*Rule, error) { // CreateRule creates a new policy rule. // If projectID is non-empty, the rule is created in that project. func (c *Client) CreateRule(ctx context.Context, projectID string, input CreateRuleInput) (*Rule, error) { - path := withProjectQuery("/v1/rules", projectID) var rule Rule - if err := c.do(ctx, http.MethodPost, path, input, &rule); err != nil { + if err := c.doProject(ctx, http.MethodPost, "/v1/rules", projectID, input, &rule); err != nil { return nil, fmt.Errorf("creating rule: %w", err) } return &rule, nil } // UpdateRule updates an existing policy rule and returns the updated rule. +// The PATCH endpoint returns {success:true}, so the rule is re-fetched. func (c *Client) UpdateRule(ctx context.Context, id string, input UpdateRuleInput) (*Rule, error) { - var rule Rule - if err := c.do(ctx, http.MethodPatch, "/v1/rules/"+id, input, &rule); err != nil { + if err := c.do(ctx, http.MethodPatch, "/v1/rules/"+id, input, nil); err != nil { return nil, fmt.Errorf("updating rule: %w", err) } - return &rule, nil + return c.GetRule(ctx, id) } // DeleteRule deletes a policy rule by ID. @@ -94,3 +108,39 @@ func (c *Client) DeleteRule(ctx context.Context, id string) error { } return nil } + +// GetRulePermissions returns the layered app-permission states for a +// provider at the project scope (Defaults = all-agents layer, ByAgent = +// per-agent override layers). +func (c *Client) GetRulePermissions(ctx context.Context, provider string) (*AppPermissionStates, error) { + var states AppPermissionStates + if err := c.do(ctx, http.MethodGet, "/v1/rules/permissions/"+provider, nil, &states); err != nil { + return nil, fmt.Errorf("getting rule permissions: %w", err) + } + return &states, nil +} + +// SetRulePermissions updates app-permission states for a provider at the +// project scope. Input.AgentID targets one agent's override layer; the +// "inherit" permission removes an agent's override for a tool. +func (c *Client) SetRulePermissions(ctx context.Context, provider string, input SetPermissionsInput) error { + var resp any + if err := c.do(ctx, http.MethodPut, "/v1/rules/permissions/"+provider, input, &resp); err != nil { + return fmt.Errorf("setting rule permissions: %w", err) + } + return nil +} + +// OverlapCount is the number of custom rules overlapping an app's hosts. +type OverlapCount struct { + Count int `json:"count"` +} + +// GetRuleOverlap counts custom rules overlapping an app's hosts (project). +func (c *Client) GetRuleOverlap(ctx context.Context, provider string) (*OverlapCount, error) { + var count OverlapCount + if err := c.do(ctx, http.MethodGet, "/v1/rules/overlap/"+provider, nil, &count); err != nil { + return nil, fmt.Errorf("getting rule overlap: %w", err) + } + return &count, nil +} diff --git a/internal/api/secrets.go b/internal/api/secrets.go index 51dba52..18f48af 100644 --- a/internal/api/secrets.go +++ b/internal/api/secrets.go @@ -14,6 +14,10 @@ type Secret struct { HostPattern string `json:"hostPattern"` PathPattern *string `json:"pathPattern"` InjectionConfig *InjectionConfig `json:"injectionConfig"` + ValueSource string `json:"valueSource,omitempty"` + OpRef string `json:"opRef,omitempty"` + Scope *string `json:"scope,omitempty"` + Metadata any `json:"metadata,omitempty"` CreatedAt string `json:"createdAt"` TypeLabel string `json:"typeLabel,omitempty"` Preview string `json:"preview,omitempty"` @@ -21,19 +25,29 @@ type Secret struct { } // InjectionConfig describes how a secret is injected into requests. -// Either HeaderName or ParamName should be set, not both. +// Either HeaderName or ParamName should be set, not both. The path fields +// carry path-injection configs (decoded for display; the CLI flags don't +// build them — use --json). type InjectionConfig struct { - HeaderName string `json:"headerName,omitempty"` - ValueFormat string `json:"valueFormat,omitempty"` - ParamName string `json:"paramName,omitempty"` - ParamFormat string `json:"paramFormat,omitempty"` + HeaderName string `json:"headerName,omitempty"` + ValueFormat string `json:"valueFormat,omitempty"` + ParamName string `json:"paramName,omitempty"` + ParamFormat string `json:"paramFormat,omitempty"` + PathTemplate string `json:"pathTemplate,omitempty"` + PathRegex string `json:"pathRegex,omitempty"` + PathReplacement string `json:"pathReplacement,omitempty"` } -// CreateSecretInput is the request body for creating a secret. +// CreateSecretInput is the request body for creating a secret. The +// valueSource/opRef/opDisplay fields carry 1Password-sourced secrets via +// --json payloads. type CreateSecretInput struct { Name string `json:"name"` Type string `json:"type"` - Value string `json:"value"` + Value string `json:"value,omitempty"` + ValueSource string `json:"valueSource,omitempty"` + OpRef string `json:"opRef,omitempty"` + OpDisplay any `json:"opDisplay,omitempty"` HostPattern string `json:"hostPattern"` PathPattern string `json:"pathPattern,omitempty"` InjectionConfig *InjectionConfig `json:"injectionConfig,omitempty"` @@ -41,7 +55,10 @@ type CreateSecretInput struct { // UpdateSecretInput is the request body for updating a secret. type UpdateSecretInput struct { + Name *string `json:"name,omitempty"` Value *string `json:"value,omitempty"` + ValueSource *string `json:"valueSource,omitempty"` + OpRef *string `json:"opRef,omitempty"` HostPattern *string `json:"hostPattern,omitempty"` PathPattern *string `json:"pathPattern,omitempty"` InjectionConfig *InjectionConfig `json:"injectionConfig,omitempty"` @@ -50,9 +67,8 @@ type UpdateSecretInput struct { // ListSecrets returns all secrets for the authenticated user. // If projectID is non-empty, results are scoped to that project. func (c *Client) ListSecrets(ctx context.Context, projectID string) ([]Secret, error) { - path := withProjectQuery("/v1/secrets", projectID) var secrets []Secret - if err := c.do(ctx, http.MethodGet, path, nil, &secrets); err != nil { + if err := c.doProject(ctx, http.MethodGet, "/v1/secrets", projectID, nil, &secrets); err != nil { return nil, fmt.Errorf("listing secrets: %w", err) } return secrets, nil @@ -61,9 +77,8 @@ func (c *Client) ListSecrets(ctx context.Context, projectID string) ([]Secret, e // CreateSecret creates a new secret. // If projectID is non-empty, the secret is created in that project. func (c *Client) CreateSecret(ctx context.Context, projectID string, input CreateSecretInput) (*Secret, error) { - path := withProjectQuery("/v1/secrets", projectID) var secret Secret - if err := c.do(ctx, http.MethodPost, path, input, &secret); err != nil { + if err := c.doProject(ctx, http.MethodPost, "/v1/secrets", projectID, input, &secret); err != nil { return nil, fmt.Errorf("creating secret: %w", err) } return &secret, nil diff --git a/internal/api/user.go b/internal/api/user.go index eac7f83..20dfe5d 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -28,6 +28,16 @@ func (c *Client) GetUser(ctx context.Context) (*User, error) { return &user, nil } +// UpdateProfile updates the authenticated user's display name. +func (c *Client) UpdateProfile(ctx context.Context, name string) (*User, error) { + var user User + body := map[string]string{"name": name} + if err := c.do(ctx, http.MethodPatch, "/v1/user", body, &user); err != nil { + return nil, fmt.Errorf("updating profile: %w", err) + } + return &user, nil +} + // GetAPIKey returns the authenticated user's API key. func (c *Client) GetAPIKey(ctx context.Context) (*APIKeyResponse, error) { var resp APIKeyResponse diff --git a/pkg/exitcode/exitcode.go b/pkg/exitcode/exitcode.go index 606432b..5ace42a 100644 --- a/pkg/exitcode/exitcode.go +++ b/pkg/exitcode/exitcode.go @@ -6,6 +6,7 @@ const ( AuthRequired = 2 NotFound = 3 Conflict = 4 + Forbidden = 5 ) // String codes for JSON error responses (used with output.Error). @@ -14,4 +15,5 @@ const ( CodeAuthRequired = "AUTH_REQUIRED" CodeNotFound = "NOT_FOUND" CodeConflict = "CONFLICT" + CodeForbidden = "FORBIDDEN" ) diff --git a/skills/onecli/SKILL.md b/skills/onecli/SKILL.md index 6f2ae8a..dcb2e37 100644 --- a/skills/onecli/SKILL.md +++ b/skills/onecli/SKILL.md @@ -16,7 +16,7 @@ onecli auth status 2. **Always `list` before acting** — get IDs from list commands, then pass explicit IDs 3. **Use `--fields` on list commands** — request only the fields you need 4. **Use `--dry-run` before mutating** — preview changes before applying them -5. **Check exit codes** — 0 = success, 1 = error, 2 = auth required, 3 = not found, 4 = conflict +5. **Check exit codes** — 0 = success, 1 = error, 2 = auth required, 3 = not found, 4 = conflict, 5 = forbidden (do not retry) ## Common Workflows