diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c8bdf..cbe6a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added + +- **`c1i mcp classifiers`: manage MCP gateway AI governance.** This renames + the former guardrails concept in c1i's user-facing CLI. List, inspect, + create, update, and delete named classifiers; bind them to enforcement + targets; inspect and instantiate templates; splice a template rule; manage + the singleton agent policy; and manage or search tool gates. Nested policy + and rule updates use JSON files and an explicit update mask so an ordered + cascade is never replaced accidentally. - **`c1i access-reviews`: manage access-review campaigns and reports.** List and inspect campaigns, create and update a campaign from validated JSON input, list generated reports, and request JSON, CSV, or XLSX report diff --git a/README.md b/README.md index 1a903ab..3171315 100644 --- a/README.md +++ b/README.md @@ -418,11 +418,51 @@ c1i mcp bindings delete --app-id --connector-id --toolset-id - c1i mcp bindings by-tools --app-id --connector-id --tool-id [--tool-id ...] # --tool-id max 32 c1i mcp bindings history --app-id --connector-id (--toolset-id | --tool-id ) [--page-size N] [--limit N] +# Classifiers (AI governance for MCP gateways) +c1i mcp classifiers list [--page-size N] [--page-token TOKEN] [--limit N] +c1i mcp classifiers get +c1i mcp classifiers create --body-file classifier.json +c1i mcp classifiers update --body-file classifier.json --update-mask +c1i mcp classifiers delete +c1i mcp classifiers bindings list [--page-size N] [--page-token TOKEN] [--limit N] +c1i mcp classifiers bindings create --body-file binding.json +c1i mcp classifiers bindings delete +c1i mcp classifiers templates list [--latest-only] [--page-size N] [--page-token TOKEN] [--limit N] +c1i mcp classifiers templates get [--template-version ] +c1i mcp classifiers templates instantiate [--body-file params.json] +c1i mcp classifiers templates add-rule [--body-file params.json] +c1i mcp classifiers policy show +c1i mcp classifiers policy update --body-file policy.json --update-mask +c1i mcp classifiers tool-gates list [--page-size N] [--page-token TOKEN] [--limit N] +c1i mcp classifiers tool-gates search [--query ] [--page-size N] [--page-token TOKEN] [--limit N] +c1i mcp classifiers tool-gates get +c1i mcp classifiers tool-gates create --body-file tool-gate.json +c1i mcp classifiers tool-gates update --body-file tool-gate.json --update-mask +c1i mcp classifiers tool-gates delete + # Gateway (verify end to end: list and invoke tools over the live MCP gateway) c1i mcp gateway list-tools [--full] [--gateway-url ] c1i mcp gateway call [--args '{"k":"v"}'] [--gateway-url ] ``` +**`mcp classifiers`** manages the AI-governance API introduced for MCP +gateways. Named classifiers are reusable ordered rule cascades; bindings attach +one to an `AGENT` or `GATEWAY` target. `policy` is the separate, tenant-wide +agent policy. A rule with an empty `celCondition` matches every tool call, and +rules execute in order, so read the current object before changing `rules`. + +`create` accepts a resource JSON object: `Classifier` for classifiers, +`ClassifierBinding` for bindings, and the full create request for tool gates. +`classifiers update` must include the classifier's required `displayName`, +even when the mask changes another field; read, edit, and resubmit the current +object. Every update requires `--update-mask`; include `rules` only when +deliberately replacing the complete cascade. `templates get` shows required +parameter keys. `instantiate` and `add-rule` accept an optional +JSON object with `templateVersion`, `params`, and, for `add-rule`, `ruleIndex` +or `insertIndex`; the positional IDs always select the template and +classifier. A tool gate's `filter` must select either `builtInPattern` or +`celExpression`, not both. + **Auth for `register` / `update-credentials`:** convenience flags cover the simple methods — `--auth none`, `--auth bearer-token --bearer-token TOKEN`, `--auth custom-header --header-name NAME --header-value VALUE`, `--auth basic-auth --basic-auth-username USER --basic-auth-password PASS`. For OAuth2 / AWS SigV4 / Google service-account auth, pass the full config object via `--hosted-config-file` / `--external-config-file` (JSON file, or `-` for stdin) — generate a ready-to-edit skeleton with `--print-config-template --auth [--type hosted]` instead of hand-writing it. Secrets are sealed server-side; reads only ever return `*_configured` booleans, never the values. `--token-sharing shared|per-user` sets the server's token-sharing mode (case-insensitive; `per_user`/`peruser` are also accepted). Per the register help, `per-user` is only valid with `oauth2` in authorization-code or passthrough mode, `bearerToken`, `customHeader`, or `basicAuth`. Note that a read-back can legitimately differ from what you sent: the backend may store a *resolved* OAuth2 grant such as `..._MODE_AUTHORIZATION_CODE` in place of the input mode, so that is a normal round-trip, not a bug. `--source-app-id` names the source app for a connector-backed HOSTED server. `--data-sensitivity`, `--tool-prefix`, `--require-tool-approval` and `--user-id` (repeatable — sets the connector's integration owners) can all be set at `register` time, not only via `update`. `mcp tools approve` is the standard post-registration step: newly discovered tools (from `register` or `resync-tools`) start in `PENDING_REVIEW`, and an admin approves them for the gateway to proxy calls. It takes one or more tool ids — the API has no batch approve, so each id is a separate request, but one invocation covers a whole toolset (pipe `mcp tools search --app-id --connector-id --state pending --fields id | jq -r .id`). History endpoints return records newest-first. diff --git a/cmd/agents.md b/cmd/agents.md index 036c22e..f500cc7 100644 --- a/cmd/agents.md +++ b/cmd/agents.md @@ -123,8 +123,11 @@ applies either way. Access-profile lifecycle, requestable and visibility entitlements, and bundle automation are first-class under `access-profiles`; use its nested `--help` to choose a command. Use `entitlements proxy-bindings` for directional -entitlement-to-entitlement links, and `access-reviews` for campaign lists, -lifecycle calls, and reports. +entitlement-to-entitlement links, `access-reviews` for campaign lists, +lifecycle calls, and reports, and `mcp classifiers` for AI-governance +classifiers, bindings, templates, the singleton agent policy, and tool gates. +Classifier creates and updates take JSON because their nested, ordered rules +must be preserved; read first and use the explicit `--update-mask` for updates. The cobra tree never drifts from what's implemented. Step down it with `--help` at each level: @@ -143,12 +146,11 @@ A few wire conventions if you build a raw request: GET endpoints take `page_size`/`page_token` as snake_case query params; POST search endpoints take `pageSize`/`pageToken` (camelCase) in the body; response pagination is always `nextPageToken`. List/search responses wrap items under `"list"` — -except the MCP admin endpoints (`mcp_tools`, `mcp_toolsets`, -`tool_bindings`), which use a resource-named key (`"tools"`, `"profiles"`, -`"bindings"`) instead. `--paginate` unwraps whichever field it finds, but pass -`--list-key ` to name it yourself rather than hand-rolling the loop when -auto-detection picks the wrong array. GET and DELETE refuse a body by default; -the few endpoints that need one on DELETE (e.g. `remove-membership`) want +except the MCP admin endpoints, which use resource-named keys (`"tools"`, +`"profiles"`, `"bindings"`, `"classifiers"`, or `"templates"`). `--paginate` +unwraps whichever field it finds, but pass `--list-key ` to name it +yourself rather than hand-rolling the loop when auto-detection picks the wrong +array. GET and DELETE refuse a body by default; the few endpoints that need `--allow-delete-body`. The UI's "campaign" is the API's access review — a campaign ID from a URL is the access review `id` directly, and the UI's "access profile" is the API's catalog: `c1i access-profiles list`, `/api/v1/catalogs`, whose diff --git a/cmd/get_unwrap_guard_test.go b/cmd/get_unwrap_guard_test.go index 4a035a7..9227463 100644 --- a/cmd/get_unwrap_guard_test.go +++ b/cmd/get_unwrap_guard_test.go @@ -150,6 +150,41 @@ func getUnwrapCases() []getUnwrapCase { payloadPath: []string{"profile"}, wantKeys: []string{"id", "displayName"}, }, + { + name: "mcp classifiers get", + cmd: mcpClassifiersGetCmd, + args: []string{"classifier-1"}, + idKey: "id", + body: `{"classifier":{"id":"classifier-1","displayName":"Observe"}}`, + payloadPath: []string{"classifier"}, + wantKeys: []string{"id", "displayName"}, + }, + { + name: "mcp classifiers templates get", + cmd: mcpClassifierTemplatesGetCmd, + args: []string{"template-1"}, + idKey: "id", + body: `{"template":{"id":"template-1","displayName":"Balanced"}}`, + payloadPath: []string{"template"}, + wantKeys: []string{"id", "displayName"}, + }, + { + name: "mcp classifiers tool-gates get", + cmd: mcpClassifierToolGatesGetCmd, + args: []string{"gate-1"}, + idKey: "id", + body: `{"toolGate":{"id":"gate-1","displayName":"Destructive"}}`, + payloadPath: []string{"toolGate"}, + wantKeys: []string{"id", "displayName"}, + }, + { + name: "mcp classifiers policy show", + cmd: mcpClassifierPolicyShowCmd, + idKey: "agentId", + body: `{"policy":{"agentId":"agent-1","defaultOutcome":"AGENT_CLASSIFIER_RULE_OUTCOME_ALLOWED"}}`, + payloadPath: []string{"policy"}, + wantKeys: []string{"agentId", "defaultOutcome"}, + }, { name: "mcp toolsets get-by-entitlement", cmd: mcpToolsetsGetByEntitlementCmd, diff --git a/cmd/mcp_classifiers.go b/cmd/mcp_classifiers.go new file mode 100644 index 0000000..636fe04 --- /dev/null +++ b/cmd/mcp_classifiers.go @@ -0,0 +1,569 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/ConductorOne/c1i/internal/client" + "github.com/spf13/cobra" +) + +var mcpClassifiersCmd = &cobra.Command{ + Use: "classifiers", + Short: "Manage MCP gateway classifiers", + Long: `Manage the AI-governance classifier surfaces used with the MCP gateway. + +A classifier is a reusable ordered rule cascade. Bind it to an enforcement +surface with "bindings create"; rules may reference tool gates. Templates +instantiate curated classifiers and their dependent gates. The singleton +"policy" surface is separate from named classifiers. + +Create and update commands take JSON objects through --body-file (or "-" for +standard input), preserving nested CEL rules, hooks, gates, and template +parameters without a lossy flag translation. Updates require --update-mask as +an explicit safeguard: including "rules" replaces the entire ordered cascade.`, +} + +var mcpClassifiersListCmd = &cobra.Command{ + Use: "list", + Short: "List classifiers (NDJSON output)", + RunE: func(cmd *cobra.Command, _ []string) error { + return listClassifierPages(cmd, "classifiers", classifierRow, func(c *client.Client, pageSize int, pageToken string) ([]byte, error) { + params := pageParams(pageSize, pageToken) + return c.Get(cmd.Context(), "/api/v1/classifiers", params) + }) + }, +} + +var mcpClassifiersGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a classifier (pretty JSON)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return getClassifierResource(cmd, client.Path("/api/v1/classifiers/%s", args[0]), "classifier") + }, +} + +var mcpClassifiersCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a classifier from a JSON object (pretty JSON)", + Long: `Create a named, reusable classifier. + +Pass the Classifier object (not the CreateClassifierRequest wrapper) with +--body-file. displayName is required by C1. Rules are evaluated in listed order, +first match wins; leave a new rule's id empty for C1 to assign it.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + classifier, err := readRequiredJSONObject(cmd, "body-file") + if err != nil { + return err + } + return postClassifierMutation(cmd, "/api/v1/classifiers", map[string]any{"classifier": classifier}) + }, +} + +var mcpClassifiersUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update selected classifier fields from JSON (pretty JSON)", + Long: `Update a classifier with a complete Classifier JSON object and an explicit +--update-mask. C1 requires displayName even when it is not in the mask. The +positional id overrides any id in the file. Use camelCase proto field paths; +for example, --update-mask description,defaultOutcome. + +Including rules in --update-mask replaces every rule in evaluation order. Read +and preserve the existing classifier first when changing rules.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := readClassifierUpdateBody(cmd, args[0]) + if err != nil { + return err + } + return postClassifierMutation(cmd, client.Path("/api/v1/classifiers/%s", args[0]), body) + }, +} + +var mcpClassifiersDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a classifier (pretty JSON)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return postClassifierMutation(cmd, client.Path("/api/v1/classifiers/%s/delete", args[0]), map[string]any{}) + }, +} + +var mcpClassifierBindingsCmd = &cobra.Command{ + Use: "bindings", + Short: "Manage classifier-to-enforcement-surface bindings", +} + +var mcpClassifierBindingsListCmd = &cobra.Command{ + Use: "list", + Short: "List classifier bindings (NDJSON output)", + RunE: func(cmd *cobra.Command, _ []string) error { + return listClassifierPages(cmd, "bindings", classifierBindingRow, func(c *client.Client, pageSize int, pageToken string) ([]byte, error) { + return c.Get(cmd.Context(), "/api/v1/classifier_bindings", pageParams(pageSize, pageToken)) + }) + }, +} + +var mcpClassifierBindingsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Bind a classifier from a JSON object (pretty JSON)", + Long: `Attach a classifier to an enforcement surface. + +Pass the ClassifierBinding object (not the CreateClassifierBindingRequest +wrapper) through --body-file. surface and classifierId are required. targetId +identifies the applicable agent or gateway target when the selected surface +uses one.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + binding, err := readRequiredJSONObject(cmd, "body-file") + if err != nil { + return err + } + return postClassifierMutation(cmd, "/api/v1/classifier_bindings", map[string]any{"binding": binding}) + }, +} + +var mcpClassifierBindingsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a classifier binding (pretty JSON)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return postClassifierMutation(cmd, client.Path("/api/v1/classifier_bindings/%s/delete", args[0]), map[string]any{}) + }, +} + +var mcpClassifierTemplatesCmd = &cobra.Command{ + Use: "templates", + Short: "Inspect and instantiate classifier templates", +} + +var mcpClassifierTemplatesListCmd = &cobra.Command{ + Use: "list", + Short: "List classifier templates (NDJSON output)", + RunE: func(cmd *cobra.Command, _ []string) error { + latestOnly, _ := cmd.Flags().GetBool("latest-only") + return listClassifierPages(cmd, "templates", classifierTemplateRow, func(c *client.Client, pageSize int, pageToken string) ([]byte, error) { + body := map[string]any{"pageSize": pageSize} + if pageToken != "" { + body["pageToken"] = pageToken + } + if latestOnly { + body["latestOnly"] = true + } + return c.Post(cmd.Context(), "/api/v1/classifier_templates/list", body) + }) + }, +} + +var mcpClassifierTemplatesGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a classifier template revision (pretty JSON)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + params := map[string]string{} + if version, _ := cmd.Flags().GetString("template-version"); version != "" { + params["template_version"] = version + } + return getClassifierResourceWithParams(cmd, client.Path("/api/v1/classifier_templates/%s", args[0]), params, "template") + }, +} + +var mcpClassifierTemplatesInstantiateCmd = &cobra.Command{ + Use: "instantiate ", + Short: "Instantiate a classifier template (pretty JSON)", + Long: `Instantiate a template into a tenant-owned, editable classifier. + +--body-file is optional. When supplied, pass optional request fields: +templateVersion, displayName, and params. Required params are declared by +"templates get" and validated by C1.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := readOptionalClassifierJSONObject(cmd, "body-file") + if err != nil { + return err + } + return postClassifierMutation(cmd, client.Path("/api/v1/classifier_templates/%s/instantiate", args[0]), body) + }, +} + +var mcpClassifierTemplatesAddRuleCmd = &cobra.Command{ + Use: "add-rule ", + Short: "Add one template rule to a classifier (pretty JSON)", + Long: `Insert one rule from a template into a classifier. + +--body-file is optional. It may contain templateVersion, ruleIndex, +insertIndex, and params. The positional classifier and template IDs are +canonical. ruleIndex defaults to 0; insertIndex at or past the end appends.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := readOptionalClassifierJSONObject(cmd, "body-file") + if err != nil { + return err + } + body["templateId"] = args[1] + return postClassifierMutation(cmd, client.Path("/api/v1/classifiers/%s/rules/from_template", args[0]), body) + }, +} + +var mcpClassifierPolicyCmd = &cobra.Command{ + Use: "policy", + Short: "Inspect and update the singleton agent classifier policy", +} + +var mcpClassifierPolicyShowCmd = &cobra.Command{ + Use: "show", + Short: "Show the singleton agent classifier policy (pretty JSON)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return getClassifierResource(cmd, "/api/v1/settings/ai-governance/agent-classifier-policy", "policy") + }, +} + +var mcpClassifierPolicyUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update selected singleton policy fields from JSON (pretty JSON)", + Long: `Update the tenant's singleton agent classifier policy using a partial +AgentClassifierPolicy JSON object and --update-mask. The policy is implicitly +created by its first update. Including rules in the mask replaces the entire +first-match-wins cascade in the supplied order.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := readMaskedClassifierObject(cmd, "policy", "") + if err != nil { + return err + } + return postClassifierMutation(cmd, "/api/v1/settings/ai-governance/agent-classifier-policy", body) + }, +} + +var mcpClassifierToolGatesCmd = &cobra.Command{ + Use: "tool-gates", + Short: "Manage classifier tool gates", +} + +var mcpClassifierToolGatesListCmd = &cobra.Command{ + Use: "list", + Short: "List tool gates (NDJSON output)", + RunE: func(cmd *cobra.Command, _ []string) error { + return listClassifierPages(cmd, "list", toolGateRow, func(c *client.Client, pageSize int, pageToken string) ([]byte, error) { + return c.Get(cmd.Context(), "/api/v1/tool_gates", pageParams(pageSize, pageToken)) + }) + }, +} + +var mcpClassifierToolGatesSearchCmd = &cobra.Command{ + Use: "search", + Short: "Search tool gates (NDJSON output)", + RunE: func(cmd *cobra.Command, _ []string) error { + query, _ := cmd.Flags().GetString("query") + return listClassifierPages(cmd, "list", toolGateRow, func(c *client.Client, pageSize int, pageToken string) ([]byte, error) { + body := map[string]any{"query": query, "pageSize": pageSize} + if pageToken != "" { + body["pageToken"] = pageToken + } + return c.Post(cmd.Context(), "/api/v1/search/tool_gates", body) + }) + }, +} + +var mcpClassifierToolGatesGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a tool gate (pretty JSON)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return getClassifierResource(cmd, client.Path("/api/v1/tool_gates/%s", args[0]), "toolGate") + }, +} + +var mcpClassifierToolGatesCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a tool gate from JSON (pretty JSON)", + Long: `Create a tool gate from the full ToolGatesServiceCreateRequest JSON object. + +displayName and grantPolicyId are required. filter may set a CEL expression or +a built-in pattern, but never both; C1 resolves a built-in pattern to its CEL +expression.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := readRequiredJSONObject(cmd, "body-file") + if err != nil { + return err + } + return postClassifierMutation(cmd, "/api/v1/tool_gates", body) + }, +} + +var mcpClassifierToolGatesUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update selected tool-gate fields from JSON (pretty JSON)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := readMaskedClassifierObject(cmd, "toolGate", args[0]) + if err != nil { + return err + } + return postClassifierMutation(cmd, client.Path("/api/v1/tool_gates/%s", args[0]), body) + }, +} + +var mcpClassifierToolGatesDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a tool gate (pretty JSON)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return postClassifierMutation(cmd, client.Path("/api/v1/tool_gates/%s/delete", args[0]), map[string]any{}) + }, +} + +type classifierPageFetcher func(c *client.Client, pageSize int, pageToken string) ([]byte, error) +type classifierRowFn func(item map[string]any) map[string]any + +func listClassifierPages(cmd *cobra.Command, listKey string, row classifierRowFn, fetch classifierPageFetcher) error { + baseURL, err := GetBaseURL() + if err != nil { + return err + } + c, err := newListClient(cmd, baseURL) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + pageToken, _ := cmd.Flags().GetString("page-token") + manualPaging := cmd.Flags().Changed("page-token") + requestedPageSize := pageSizeFlag(cmd) + limit := getIntFlag(cmd, "limit") + enc := newEmitter(cmd) + for !limitReached(enc.Written(), limit) { + pageSize := requestedPageSize + if !enc.Filtered() { + pageSize = effectivePageSize(requestedPageSize, limit, enc.Written()) + } + data, err := fetch(c, pageSize, pageToken) + if err != nil { + return fmt.Errorf("API error: %w", err) + } + items, nextPageToken, err := extractListAndToken(data, listKey) + if err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + for _, raw := range items { + var item map[string]any + if err := json.Unmarshal(raw, &item); err != nil { + return fmt.Errorf("failed to parse response item: %w", err) + } + if err := enc.Encode(row(item)); err != nil { + return err + } + if limitReached(enc.Written(), limit) { + return nil + } + } + if nextPageToken == "" || manualPaging { + return nil + } + pageToken = nextPageToken + } + return nil +} + +func pageParams(pageSize int, pageToken string) map[string]string { + params := map[string]string{"page_size": strconv.Itoa(pageSize)} + if pageToken != "" { + params["page_token"] = pageToken + } + return params +} + +func getClassifierResource(cmd *cobra.Command, path, key string) error { + return getClassifierResourceWithParams(cmd, path, nil, key) +} + +func getClassifierResourceWithParams(cmd *cobra.Command, path string, params map[string]string, key string) error { + baseURL, err := GetBaseURL() + if err != nil { + return err + } + c, err := newClient(cmd, baseURL) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + data, err := c.Get(cmd.Context(), path, params) + if err != nil { + return fmt.Errorf("API error: %w", err) + } + return writeNamedResource(cmd, data, key) +} + +func postClassifierMutation(cmd *cobra.Command, path string, body map[string]any) error { + if dryRunActive() { + return printDryRun(cmd, "POST", path, body) + } + baseURL, err := GetBaseURL() + if err != nil { + return err + } + c, err := newClient(cmd, baseURL) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + data, err := c.Post(cmd.Context(), path, body) + if err != nil { + return fmt.Errorf("API error: %w", err) + } + return writeRawObject(cmd, data) +} + +func readOptionalClassifierJSONObject(cmd *cobra.Command, flag string) (map[string]any, error) { + file, _ := cmd.Flags().GetString(flag) + if file == "" { + return map[string]any{}, nil + } + return readRequiredJSONObject(cmd, flag) +} + +func readClassifierUpdateBody(cmd *cobra.Command, id string) (map[string]any, error) { + body, err := readMaskedClassifierObject(cmd, "classifier", id) + if err != nil { + return nil, err + } + classifier := body["classifier"].(map[string]any) + if _, ok := classifier["displayName"].(string); !ok { + return nil, &usageError{fmt.Errorf("classifier update body must include displayName")} + } + return body, nil +} + +func readMaskedClassifierObject(cmd *cobra.Command, field, id string) (map[string]any, error) { + object, err := readRequiredJSONObject(cmd, "body-file") + if err != nil { + return nil, err + } + mask, _ := cmd.Flags().GetString("update-mask") + if strings.TrimSpace(mask) == "" { + return nil, &usageError{fmt.Errorf("--update-mask is required")} + } + if id != "" { + object["id"] = id + } + return map[string]any{field: object, "updateMask": mask}, nil +} + +func classifierRow(item map[string]any) map[string]any { + return map[string]any{ + "id": stringField(item, "id"), + "display_name": stringField(item, "displayName"), + "description": stringField(item, "description"), + "applicable_type": stringField(item, "applicableType"), + "managed_by": stringField(item, "managedBy"), + "default_outcome": stringField(item, "defaultOutcome"), + "rule_count": len(arrayField(item, "rules")), + "template_id": nestedStringField(item, "templateRef", "templateId"), + "template_version": nestedStringField(item, "templateRef", "version"), + "deleted_at": nilIfBlank(item["deletedAt"]), + } +} + +func classifierBindingRow(item map[string]any) map[string]any { + return map[string]any{ + "id": stringField(item, "id"), + "classifier_id": stringField(item, "classifierId"), + "surface": stringField(item, "surface"), + "target_id": stringField(item, "targetId"), + "deleted_at": nilIfBlank(item["deletedAt"]), + } +} + +func classifierTemplateRow(item map[string]any) map[string]any { + return map[string]any{ + "id": stringField(item, "id"), + "template_version": stringField(item, "templateVersion"), + "display_name": stringField(item, "displayName"), + "applicable_type": stringField(item, "applicableType"), + "distribution": stringField(item, "distribution"), + "managed_by": stringField(item, "managedBy"), + "preset_tier": stringField(item, "presetTier"), + "is_latest": boolField(item, "isLatest"), + "rule_count": len(arrayField(item, "rules")), + "required_param_count": len(arrayField(item, "requiredParams")), + "deleted_at": nilIfBlank(item["deletedAt"]), + } +} + +func toolGateRow(item map[string]any) map[string]any { + return map[string]any{ + "id": stringField(item, "id"), + "display_name": stringField(item, "displayName"), + "description": stringField(item, "description"), + "priority": item["priority"], + "enabled": boolField(item, "enabled"), + "grant_policy_id": stringField(item, "grantPolicyId"), + "built_in_pattern": nestedStringField(item, "filter", "builtInPattern"), + "deleted_at": nilIfBlank(item["deletedAt"]), + } +} + +func stringField(item map[string]any, key string) string { + value, _ := item[key].(string) + return value +} + +func nestedStringField(item map[string]any, parent, key string) string { + child, _ := item[parent].(map[string]any) + return stringField(child, key) +} + +func arrayField(item map[string]any, key string) []any { + value, _ := item[key].([]any) + return value +} + +func boolField(item map[string]any, key string) bool { + value, _ := item[key].(bool) + return value +} + +func nilIfBlank(value any) any { + if value == nil || value == "" { + return nil + } + return value +} + +func init() { + addPaginationFlags(mcpClassifiersListCmd) + addPaginationFlags(mcpClassifierBindingsListCmd) + mcpClassifierTemplatesListCmd.Flags().Bool("latest-only", false, "Return only the latest revision of each template") + addPaginationFlags(mcpClassifierTemplatesListCmd) + mcpClassifierTemplatesGetCmd.Flags().String("template-version", "", "Template version (default: latest)") + addPaginationFlags(mcpClassifierToolGatesListCmd) + mcpClassifierToolGatesSearchCmd.Flags().String("query", "", "Text to search for (default: all tool gates)") + addPaginationFlags(mcpClassifierToolGatesSearchCmd) + + for _, command := range []*cobra.Command{ + mcpClassifiersCreateCmd, + mcpClassifiersUpdateCmd, + mcpClassifierBindingsCreateCmd, + mcpClassifierTemplatesInstantiateCmd, + mcpClassifierTemplatesAddRuleCmd, + mcpClassifierPolicyUpdateCmd, + mcpClassifierToolGatesCreateCmd, + mcpClassifierToolGatesUpdateCmd, + } { + command.Flags().String("body-file", "", "JSON object file (or \"-\" for stdin)") + } + for _, command := range []*cobra.Command{mcpClassifiersUpdateCmd, mcpClassifierPolicyUpdateCmd, mcpClassifierToolGatesUpdateCmd} { + command.Flags().String("update-mask", "", "Comma-separated camelCase fields to update (required)") + } + + mcpClassifiersCmd.AddCommand(mcpClassifiersListCmd, mcpClassifiersGetCmd, mcpClassifiersCreateCmd, mcpClassifiersUpdateCmd, mcpClassifiersDeleteCmd) + mcpClassifierBindingsCmd.AddCommand(mcpClassifierBindingsListCmd, mcpClassifierBindingsCreateCmd, mcpClassifierBindingsDeleteCmd) + mcpClassifierTemplatesCmd.AddCommand(mcpClassifierTemplatesListCmd, mcpClassifierTemplatesGetCmd, mcpClassifierTemplatesInstantiateCmd, mcpClassifierTemplatesAddRuleCmd) + mcpClassifierPolicyCmd.AddCommand(mcpClassifierPolicyShowCmd, mcpClassifierPolicyUpdateCmd) + mcpClassifierToolGatesCmd.AddCommand(mcpClassifierToolGatesListCmd, mcpClassifierToolGatesSearchCmd, mcpClassifierToolGatesGetCmd, mcpClassifierToolGatesCreateCmd, mcpClassifierToolGatesUpdateCmd, mcpClassifierToolGatesDeleteCmd) + mcpClassifiersCmd.AddCommand(mcpClassifierBindingsCmd, mcpClassifierTemplatesCmd, mcpClassifierPolicyCmd, mcpClassifierToolGatesCmd) + mcpCmd.AddCommand(mcpClassifiersCmd) +} diff --git a/cmd/mcp_classifiers_test.go b/cmd/mcp_classifiers_test.go new file mode 100644 index 0000000..b29c988 --- /dev/null +++ b/cmd/mcp_classifiers_test.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/ConductorOne/c1i/internal/client" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func writeClassifierJSON(t *testing.T, name, content string) string { + t.Helper() + path := t.TempDir() + "/" + name + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("writing %s: %v", name, err) + } + return path +} + +func enableClassifierDryRun(t *testing.T) { + t.Helper() + original := viper.GetBool("dry_run") + viper.Set("dry_run", true) + t.Cleanup(func() { viper.Set("dry_run", original) }) +} + +func setClassifierFlag(t *testing.T, cmd *cobra.Command, name, value string) { + t.Helper() + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("setting --%s=%s: %v", name, value, err) + } +} + +func TestClassifierMutationsDryRunUsePublicRequestShapes(t *testing.T) { + classifierFile := writeClassifierJSON(t, "classifier.json", `{"displayName":"Observe MCP"}`) + bindingFile := writeClassifierJSON(t, "binding.json", `{"surface":"CLASSIFIER_TYPE_GATEWAY","classifierId":"classifier-1"}`) + templateFile := writeClassifierJSON(t, "template.json", `{"templateVersion":"v2","params":{"grant_policy_id":"policy-1"}}`) + policyFile := writeClassifierJSON(t, "policy.json", `{"defaultOutcome":"AGENT_CLASSIFIER_RULE_OUTCOME_ALLOWED"}`) + toolGateFile := writeClassifierJSON(t, "tool-gate.json", `{"displayName":"Destructive","grantPolicyId":"policy-1","filter":{"builtInPattern":"TOOL_GATE_BUILT_IN_PATTERN_DESTRUCTIVE_ACTION"}}`) + + cases := []struct { + name string + cmd *cobra.Command + args []string + bodyFile string + mask string + contains []string + absent []string + }{ + { + name: "classifier create", cmd: mcpClassifiersCreateCmd, bodyFile: classifierFile, + contains: []string{"[dry-run] POST /api/v1/classifiers", `"classifier"`, `"displayName": "Observe MCP"`}, + }, + { + name: "classifier update", cmd: mcpClassifiersUpdateCmd, args: []string{"classifier/1"}, bodyFile: classifierFile, mask: "description", + contains: []string{"/api/v1/classifiers/classifier%2F1", `"id": "classifier/1"`, `"updateMask": "description"`}, + }, + { + name: "binding create", cmd: mcpClassifierBindingsCreateCmd, bodyFile: bindingFile, + contains: []string{"/api/v1/classifier_bindings", `"binding"`, `"classifierId": "classifier-1"`}, + }, + { + name: "template instantiate", cmd: mcpClassifierTemplatesInstantiateCmd, args: []string{"template/1"}, bodyFile: templateFile, + contains: []string{"/api/v1/classifier_templates/template%2F1/instantiate", `"templateVersion": "v2"`}, + absent: []string{`"templateId"`}, + }, + { + name: "template add rule", cmd: mcpClassifierTemplatesAddRuleCmd, args: []string{"classifier/1", "template/1"}, bodyFile: templateFile, + contains: []string{"/api/v1/classifiers/classifier%2F1/rules/from_template", `"templateId": "template/1"`}, + absent: []string{`"classifierId"`}, + }, + { + name: "singleton policy update", cmd: mcpClassifierPolicyUpdateCmd, bodyFile: policyFile, mask: "defaultOutcome", + contains: []string{"/api/v1/settings/ai-governance/agent-classifier-policy", `"policy"`, `"updateMask": "defaultOutcome"`}, + }, + { + name: "tool gate create", cmd: mcpClassifierToolGatesCreateCmd, bodyFile: toolGateFile, + contains: []string{"/api/v1/tool_gates", `"grantPolicyId": "policy-1"`}, + absent: []string{`"toolGate"`}, + }, + { + name: "tool gate update", cmd: mcpClassifierToolGatesUpdateCmd, args: []string{"gate/1"}, bodyFile: toolGateFile, mask: "enabled", + contains: []string{"/api/v1/tool_gates/gate%2F1", `"toolGate"`, `"id": "gate/1"`, `"updateMask": "enabled"`}, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + resetCmdFlags(t, tt.cmd) + enableClassifierDryRun(t) + t.Setenv("C1I_URL", "https://example.invalid") + if tt.bodyFile != "" { + setClassifierFlag(t, tt.cmd, "body-file", tt.bodyFile) + } + if tt.mask != "" { + setClassifierFlag(t, tt.cmd, "update-mask", tt.mask) + } + var out bytes.Buffer + tt.cmd.SetOut(&out) + t.Cleanup(func() { tt.cmd.SetOut(nil) }) + tt.cmd.SetContext(context.Background()) + if err := tt.cmd.RunE(tt.cmd, tt.args); err != nil { + t.Fatalf("RunE: %v", err) + } + for _, want := range tt.contains { + if !strings.Contains(out.String(), want) { + t.Errorf("output %q does not contain %q", out.String(), want) + } + } + for _, unwanted := range tt.absent { + if strings.Contains(out.String(), unwanted) { + t.Errorf("output %q unexpectedly contains %q", out.String(), unwanted) + } + } + }) + } +} + +func TestClassifierUpdatesRequireExplicitMask(t *testing.T) { + resetCmdFlags(t, mcpClassifiersUpdateCmd) + setClassifierFlag(t, mcpClassifiersUpdateCmd, "body-file", writeClassifierJSON(t, "classifier.json", `{"description":"updated"}`)) + mcpClassifiersUpdateCmd.SetContext(context.Background()) + err := mcpClassifiersUpdateCmd.RunE(mcpClassifiersUpdateCmd, []string{"classifier-1"}) + if err == nil || !strings.Contains(err.Error(), "--update-mask is required") { + t.Errorf("error = %v, want missing update mask", err) + } +} + +func TestClassifierUpdateRequiresDisplayName(t *testing.T) { + resetCmdFlags(t, mcpClassifiersUpdateCmd) + enableClassifierDryRun(t) + setClassifierFlag(t, mcpClassifiersUpdateCmd, "body-file", writeClassifierJSON(t, "classifier.json", `{"description":"updated"}`)) + setClassifierFlag(t, mcpClassifiersUpdateCmd, "update-mask", "description") + mcpClassifiersUpdateCmd.SetContext(context.Background()) + err := mcpClassifiersUpdateCmd.RunE(mcpClassifiersUpdateCmd, []string{"classifier-1"}) + if err == nil || !strings.Contains(err.Error(), "include displayName") { + t.Errorf("error = %v, want missing displayName", err) + } +} + +func TestClassifierListPaginatesWithClassifierEnvelope(t *testing.T) { + var tokens []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/classifiers" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + tokens = append(tokens, r.URL.Query().Get("page_token")) + if r.URL.Query().Get("page_token") == "next" { + _, _ = w.Write([]byte(`{"classifiers":[{"id":"classifier-2","displayName":"Second"}],"nextPageToken":""}`)) + return + } + _, _ = w.Write([]byte(`{"classifiers":[{"id":"classifier-1","displayName":"First"}],"nextPageToken":"next"}`)) + })) + t.Cleanup(srv.Close) + + original := newListClient + newListClient = func(_ *cobra.Command, _ string) (*client.Client, error) { + return client.NewForTesting(srv.URL, srv.Client()), nil + } + t.Cleanup(func() { newListClient = original }) + t.Setenv("C1I_URL", "https://example.invalid") + resetCmdFlags(t, mcpClassifiersListCmd) + var out bytes.Buffer + mcpClassifiersListCmd.SetOut(&out) + t.Cleanup(func() { mcpClassifiersListCmd.SetOut(nil) }) + mcpClassifiersListCmd.SetContext(context.Background()) + if err := mcpClassifiersListCmd.RunE(mcpClassifiersListCmd, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + if got, want := strings.Join(tokens, ","), ",next"; got != want { + t.Errorf("page tokens = %q, want %q", got, want) + } + var rows []map[string]any + for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + var row map[string]any + if err := json.Unmarshal([]byte(line), &row); err != nil { + t.Fatalf("unmarshaling row %q: %v", line, err) + } + rows = append(rows, row) + } + if len(rows) != 2 || rows[0]["id"] != "classifier-1" || rows[1]["id"] != "classifier-2" { + t.Errorf("rows = %#v, want both classifier IDs in order", rows) + } +} diff --git a/cmd/unwrap.go b/cmd/unwrap.go index 77c4b81..46d03fd 100644 --- a/cmd/unwrap.go +++ b/cmd/unwrap.go @@ -20,6 +20,25 @@ func writeResource(cmd *cobra.Command, data []byte, idKey string) error { return writeObject(cmd, unwrapEnvelope(data, idKey)) } +// writeNamedResource is writeObject for an endpoint whose response envelope +// names its sole resource explicitly. It keeps envelope siblings while +// avoiding a heuristic search for a property that a singleton may not have. +func writeNamedResource(cmd *cobra.Command, data []byte, resourceKey string) error { + return writeObject(cmd, unwrapNamedEnvelope(data, resourceKey)) +} + +func unwrapNamedEnvelope(data []byte, resourceKey string) []byte { + payload, siblings, ok := splitEnvelope(data, []string{resourceKey}) + if !ok { + return data + } + merged, ok := mergeSiblings(payload, siblings) + if !ok { + return data + } + return merged +} + // unwrapEnvelope hoists a single-object response's payload to the top level, // keeping every other envelope key as a sibling — dropping them is the data // loss this unwrap exists to avoid. A shape it can't unwrap losslessly comes