From e6f531fa048bc04e81ca1aa8da0ffa51e14a57ac Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Mon, 23 Mar 2026 22:21:58 +0200 Subject: [PATCH] feat: add policy rules commands with input hardening --- README.md | 12 +- cmd/onecli/auth.go | 45 ++++++- cmd/onecli/help.go | 16 ++- cmd/onecli/main.go | 3 +- cmd/onecli/rules.go | 271 +++++++++++++++++++++++++++++++++++++++++ internal/api/rules.go | 92 ++++++++++++++ internal/api/user.go | 23 ++++ skills/onecli/SKILL.md | 24 ++++ 8 files changed, 480 insertions(+), 6 deletions(-) create mode 100644 cmd/onecli/rules.go create mode 100644 internal/api/rules.go diff --git a/README.md b/README.md index ad21c13..016a61b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OneCLI CLI -CLI for [OneCLI](https://onecli.sh) — manage agents, secrets, and configuration from the terminal. +CLI for [OneCLI](https://onecli.sh) — manage agents, secrets, rules, and configuration from the terminal. ## Install @@ -48,6 +48,16 @@ onecli secrets update --id X --value Y Update a secret onecli secrets delete --id X Delete a secret ``` +### Rules + +``` +onecli rules list List all policy rules +onecli rules get --id X Get a single rule +onecli rules create --name X --host-pattern Y ... Create a new rule +onecli rules update --id X [--action block] ... Update a rule +onecli rules delete --id X Delete a rule +``` + ### Auth ``` diff --git a/cmd/onecli/auth.go b/cmd/onecli/auth.go index 20411a3..214214b 100644 --- a/cmd/onecli/auth.go +++ b/cmd/onecli/auth.go @@ -16,9 +16,11 @@ import ( // AuthCmd is the `onecli auth` command group. 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."` + Login AuthLoginCmd `cmd:"" help:"Store API key for authentication."` + Logout AuthLogoutCmd `cmd:"" help:"Remove stored API key."` + Status AuthStatusCmd `cmd:"" help:"Show authentication status."` + ApiKey AuthApiKeyCmd `cmd:"api-key" help:"Show your current API key."` + RegenerateApiKey AuthRegenerateApiKeyCmd `cmd:"regenerate-api-key" help:"Regenerate your API key."` } // AuthLoginCmd is `onecli auth login`. @@ -131,3 +133,40 @@ func (c *AuthStatusCmd) Run(out *output.Writer) error { Name: user.Name, }) } + +// AuthApiKeyCmd is `onecli auth api-key`. +type AuthApiKeyCmd struct { + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *AuthApiKeyCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + resp, err := client.GetAPIKey(newContext()) + if err != nil { + return err + } + return out.WriteFiltered(resp, c.Fields) +} + +// AuthRegenerateApiKeyCmd is `onecli auth regenerate-api-key`. +type AuthRegenerateApiKeyCmd struct { + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AuthRegenerateApiKeyCmd) Run(out *output.Writer) error { + if c.DryRun { + return out.WriteDryRun("Would regenerate API key", nil) + } + client, err := newClient() + if err != nil { + return err + } + resp, err := client.RegenerateAPIKey(newContext()) + if err != nil { + return err + } + return out.Write(resp) +} diff --git a/cmd/onecli/help.go b/cmd/onecli/help.go index 3676d18..9fef19d 100644 --- a/cmd/onecli/help.go +++ b/cmd/onecli/help.go @@ -37,7 +37,7 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { return out.Write(HelpResponse{ Name: "onecli", Version: version, - Description: "CLI for managing OneCLI agents, secrets, and configuration.", + Description: "CLI for managing OneCLI agents, secrets, rules, and configuration.", Commands: []CommandInfo{ {Name: "agents list", Description: "List all agents."}, {Name: "agents get-default", Description: "Get the default agent."}, @@ -79,9 +79,23 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "secrets delete", Description: "Delete a secret.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the secret to delete."}, }}, + {Name: "rules list", Description: "List all policy rules."}, + {Name: "rules create", Description: "Create a new 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: "rules update", Description: "Update an existing policy rule.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the rule to update."}, + }}, + {Name: "rules delete", Description: "Delete a policy rule.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the rule to delete."}, + }}, {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 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."}, {Name: "config set ", Description: "Set a config value."}, {Name: "version", Description: "Print version information."}, diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index 0333cce..c1df065 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -22,6 +22,7 @@ type CLI struct { Help HelpCmd `cmd:"" help:"Show available commands."` Agents AgentsCmd `cmd:"" help:"Manage agents."` Secrets SecretsCmd `cmd:"" help:"Manage secrets."` + Rules RulesCmd `cmd:"" help:"Manage policy rules."` Auth AuthCmd `cmd:"" help:"Manage authentication."` Config ConfigCmd `cmd:"" help:"Manage configuration settings."` } @@ -43,7 +44,7 @@ func main() { cli := &CLI{} k, err := kong.New(cli, kong.Name("onecli"), - kong.Description("CLI for managing OneCLI agents, secrets, and configuration."), + kong.Description("CLI for managing OneCLI agents, secrets, rules, and configuration."), kong.Help(jsonHelpPrinter(out)), kong.Bind(out), ) diff --git a/cmd/onecli/rules.go b/cmd/onecli/rules.go new file mode 100644 index 0000000..24a3127 --- /dev/null +++ b/cmd/onecli/rules.go @@ -0,0 +1,271 @@ +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" +) + +// 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."` +} + +// RulesListCmd is `onecli rules list`. +type RulesListCmd 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."` + Max int `optional:"" default:"20" help:"Maximum number of results to return."` +} + +func (c *RulesListCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + rules, err := client.ListRules(newContext()) + if err != nil { + return err + } + if c.Max > 0 && len(rules) > c.Max { + rules = rules[:c.Max] + } + if c.Quiet != "" { + return out.WriteQuiet(rules, c.Quiet) + } + return out.WriteFiltered(rules, c.Fields) +} + +// RulesGetCmd is `onecli rules get`. +type RulesGetCmd struct { + ID string `required:"" help:"ID of the rule to retrieve."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *RulesGetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + rule, err := client.GetRule(newContext(), c.ID) + if err != nil { + return err + } + return out.WriteFiltered(rule, c.Fields) +} + +// RulesCreateCmd is `onecli rules create`. +type RulesCreateCmd 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'."` + 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."` + Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *RulesCreateCmd) Run(out *output.Writer) error { + var input api.CreateRuleInput + if c.Json != "" { + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + } else { + input = api.CreateRuleInput{ + Name: c.Name, + HostPattern: c.HostPattern, + PathPattern: c.PathPattern, + Method: c.Method, + Action: c.Action, + Enabled: c.Enabled, + AgentID: c.AgentID, + RateLimit: c.RateLimit, + RateLimitWindow: c.RateLimitWindow, + } + } + + if err := validateRuleInput(input.HostPattern, input.PathPattern, input.Method, input.AgentID, input.Action); err != nil { + return err + } + + if input.Action == "rate_limit" && (input.RateLimit == nil || input.RateLimitWindow == "") { + return fmt.Errorf("--rate-limit and --rate-limit-window are required when action is 'rate_limit'") + } + + if c.DryRun { + return out.WriteDryRun("Would create rule", input) + } + + client, err := newClient() + if err != nil { + return err + } + rule, err := client.CreateRule(newContext(), input) + if err != nil { + return err + } + return out.Write(rule) +} + +// RulesUpdateCmd is `onecli rules update`. +type RulesUpdateCmd struct { + ID string `required:"" help:"ID of the rule to update."` + Name string `optional:"" help:"New display name."` + 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'."` + 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."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *RulesUpdateCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + + var input api.UpdateRuleInput + if c.Json != "" { + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + } else { + if c.Name != "" { + input.Name = &c.Name + } + if c.HostPattern != "" { + input.HostPattern = &c.HostPattern + } + if c.PathPattern != "" { + input.PathPattern = &c.PathPattern + } + if c.Method != "" { + input.Method = &c.Method + } + if c.Action != "" { + input.Action = &c.Action + } + if c.Enabled != nil { + input.Enabled = c.Enabled + } + if c.AgentID != "" { + input.AgentID = &c.AgentID + } + if c.RateLimit != nil { + input.RateLimit = c.RateLimit + } + if c.RateLimitWindow != "" { + input.RateLimitWindow = &c.RateLimitWindow + } + } + + var hostPattern, pathPattern, method, agentID, action string + if input.HostPattern != nil { + hostPattern = *input.HostPattern + } + if input.PathPattern != nil { + pathPattern = *input.PathPattern + } + if input.Method != nil { + method = *input.Method + } + if input.AgentID != nil { + agentID = *input.AgentID + } + if input.Action != nil { + action = *input.Action + } + if err := validateRuleInput(hostPattern, pathPattern, method, agentID, action); err != nil { + return err + } + + if c.DryRun { + return out.WriteDryRun("Would update rule", map[string]any{"id": c.ID, "input": input}) + } + + client, err := newClient() + if err != nil { + return err + } + rule, err := client.UpdateRule(newContext(), c.ID, input) + if err != nil { + return err + } + return out.Write(rule) +} + +// RulesDeleteCmd is `onecli rules delete`. +type RulesDeleteCmd struct { + ID string `required:"" help:"ID of the rule to delete."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *RulesDeleteCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would delete rule", map[string]string{"id": c.ID}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.DeleteRule(newContext(), c.ID); err != nil { + return err + } + return out.Write(map[string]string{"status": "deleted", "id": c.ID}) +} + +// validHTTPMethods is the set of HTTP methods accepted for rule matching. +var validHTTPMethods = map[string]bool{ + "GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true, +} + +// validateRuleInput validates shared fields across create and update commands. +// Empty strings are skipped (relevant for partial updates). +func validateRuleInput(hostPattern, pathPattern, method, agentID, action string) error { + if hostPattern != "" { + if err := validate.NoControlChars(hostPattern); err != nil { + return fmt.Errorf("invalid host-pattern: %w", err) + } + } + if pathPattern != "" { + if err := validate.NoControlChars(pathPattern); err != nil { + return fmt.Errorf("invalid path-pattern: %w", err) + } + } + if method != "" { + if !validHTTPMethods[method] { + return fmt.Errorf("invalid method %q: must be one of GET, POST, PUT, PATCH, DELETE", method) + } + } + if agentID != "" { + if err := validate.ResourceID(agentID); err != nil { + 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) + } + return nil +} diff --git a/internal/api/rules.go b/internal/api/rules.go new file mode 100644 index 0000000..d4273a4 --- /dev/null +++ b/internal/api/rules.go @@ -0,0 +1,92 @@ +package api + +import ( + "context" + "fmt" + "net/http" +) + +// 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"` +} + +// 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"` +} + +// UpdateRuleInput is the request body for updating a rule. +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"` +} + +// ListRules returns all policy rules for the authenticated user. +func (c *Client) ListRules(ctx context.Context) ([]Rule, error) { + var rules []Rule + if err := c.do(ctx, http.MethodGet, "/api/rules", nil, &rules); err != nil { + return nil, fmt.Errorf("listing rules: %w", err) + } + return rules, nil +} + +// GetRule returns a single policy rule by ID. +func (c *Client) GetRule(ctx context.Context, id string) (*Rule, error) { + var rule Rule + if err := c.do(ctx, http.MethodGet, "/api/rules/"+id, nil, &rule); err != nil { + return nil, fmt.Errorf("getting rule: %w", err) + } + return &rule, nil +} + +// CreateRule creates a new policy rule. +func (c *Client) CreateRule(ctx context.Context, input CreateRuleInput) (*Rule, error) { + var rule Rule + if err := c.do(ctx, http.MethodPost, "/api/rules", 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. +func (c *Client) UpdateRule(ctx context.Context, id string, input UpdateRuleInput) (*Rule, error) { + var rule Rule + if err := c.do(ctx, http.MethodPatch, "/api/rules/"+id, input, &rule); err != nil { + return nil, fmt.Errorf("updating rule: %w", err) + } + return &rule, nil +} + +// DeleteRule deletes a policy rule by ID. +func (c *Client) DeleteRule(ctx context.Context, id string) error { + if err := c.do(ctx, http.MethodDelete, "/api/rules/"+id, nil, nil); err != nil { + return fmt.Errorf("deleting rule: %w", err) + } + return nil +} diff --git a/internal/api/user.go b/internal/api/user.go index ad3df23..666d77b 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -14,6 +14,11 @@ type User struct { CreatedAt string `json:"createdAt"` } +// APIKeyResponse is the response from the API key endpoint. +type APIKeyResponse struct { + APIKey string `json:"apiKey"` +} + // GetUser returns the authenticated user's profile. func (c *Client) GetUser(ctx context.Context) (*User, error) { var user User @@ -22,3 +27,21 @@ func (c *Client) GetUser(ctx context.Context) (*User, error) { } return &user, nil } + +// GetAPIKey returns the authenticated user's API key. +func (c *Client) GetAPIKey(ctx context.Context) (*APIKeyResponse, error) { + var resp APIKeyResponse + if err := c.do(ctx, http.MethodGet, "/api/user/api-key", nil, &resp); err != nil { + return nil, fmt.Errorf("getting API key: %w", err) + } + return &resp, nil +} + +// RegenerateAPIKey regenerates the authenticated user's API key. +func (c *Client) RegenerateAPIKey(ctx context.Context) (*APIKeyResponse, error) { + var resp APIKeyResponse + if err := c.do(ctx, http.MethodPost, "/api/user/api-key/regenerate", nil, &resp); err != nil { + return nil, fmt.Errorf("regenerating API key: %w", err) + } + return &resp, nil +} diff --git a/skills/onecli/SKILL.md b/skills/onecli/SKILL.md index 7aad0db..6f2ae8a 100644 --- a/skills/onecli/SKILL.md +++ b/skills/onecli/SKILL.md @@ -40,6 +40,23 @@ onecli agents list --quiet id onecli agents set-secrets --id --secret-ids ``` +### Block an endpoint + +```bash +onecli rules create --name "Block Gmail send" \ + --host-pattern "gmail.googleapis.com" \ + --path-pattern "/gmail/v1/users/me/messages/send" \ + --action block --method POST +``` + +### Rate limit an endpoint + +```bash +onecli rules create --name "Limit Anthropic calls" \ + --host-pattern "api.anthropic.com" \ + --action rate_limit --rate-limit 100 --rate-limit-window hour +``` + ### Check agent configuration ```bash @@ -47,6 +64,13 @@ onecli agents list --fields id,name,secretMode onecli agents secrets --id ``` +### View and regenerate API key + +```bash +onecli auth api-key +onecli auth regenerate-api-key +``` + ## Output Format All output is JSON. Errors go to stderr with this shape: