diff --git a/CLAUDE.md b/CLAUDE.md index 8cff768b..b4f49a0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ These skills live in the `feature-dev-workflow` plugin (`github.com/sourcehawk/f ## Operational rules - **TDD is the standard.** Failing test → watch it fail for the right reason → implement. One commit per task. +- **Tests assert with `testify`.** Use `github.com/stretchr/testify/assert` for checks the test should keep running past, and `require` for preconditions a failure must stop at (a non-nil error before a dereference, setup that must succeed). Bare `t.Fatal` / `t.Errorf` is the rare exception, not the default. - **Before claiming done: `make test` + `make lint`; if `frontend/` touched, also `cd frontend && npm run typecheck`.** CI gates all three; local is the cheapest place to catch failures. Race-clean is non-negotiable. - **Commit conventions:** `feat(): ...`, `fix(): ...`, `refactor(): ...`, `test(): ...`, `chore(): ...`. Area mirrors the module path. - **Never `--no-verify`, never `git add -A` / `git add .`.** Stage by name; pre-commit hooks exist for a reason. diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index 5650b5ea..29d3f6ab 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -2,13 +2,19 @@ package main import ( "context" + "encoding/json" "fmt" "os" "os/signal" "strings" "syscall" + "github.com/charmbracelet/log" "github.com/sourcehawk/triagent/pkg/mcp/agentoperator" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" "github.com/sourcehawk/triagent/pkg/mcp/git" "github.com/sourcehawk/triagent/pkg/mcp/incidentio" "github.com/sourcehawk/triagent/pkg/mcp/k8s" @@ -21,28 +27,27 @@ import ( "github.com/sourcehawk/triagent/pkg/mcp/strategies" "github.com/sourcehawk/triagent/pkg/mcp/teleport" "github.com/sourcehawk/triagent/pkg/mcp/wiki" - "github.com/charmbracelet/log" "github.com/spf13/cobra" ) // Environment variable names. Flags override env when both are set. const ( - envKubeconfig = "TRIAGENT_MCP_KUBECONFIG" - envCRDsFile = "TRIAGENT_MCP_CRDS_FILE" - envCrossplaneGroups = "TRIAGENT_MCP_CROSSPLANE_GROUPS" - envSessionDir = "TRIAGENT_MCP_SESSION_DIR" - envUserPlaybooksDir = "TRIAGENT_MCP_USER_PLAYBOOKS_DIR" - envPluginPlaybooksDir = "TRIAGENT_MCP_PLUGIN_PLAYBOOKS_DIR" - envSystemPlaybooksDir = "TRIAGENT_MCP_SYSTEM_PLAYBOOKS_DIR" - envStrategiesSubagentModel = "TRIAGENT_MCP_STRATEGIES_SUBAGENT_MODEL" - envMCPConfigPath = "TRIAGENT_MCP_CONFIG_PATH" - envGitRepo = "TRIAGENT_MCP_GIT_REPO" - envGitCacheDir = "TRIAGENT_MCP_GIT_CACHE_DIR" - envGitClaudeBinary = "TRIAGENT_MCP_GIT_CLAUDE_BINARY" - envGitFilterPrereleases = "TRIAGENT_MCP_GIT_FILTER_PRERELEASES" - envWikiServePath = "TRIAGENT_MCP_WIKI_PATH" - envWikiServeProposalsPath = "TRIAGENT_MCP_WIKI_PROPOSALS_PATH" - envWikiServeClaudeBinary = "TRIAGENT_MCP_WIKI_CLAUDE_BINARY" + envKubeconfig = "TRIAGENT_MCP_KUBECONFIG" + envCRDsFile = "TRIAGENT_MCP_CRDS_FILE" + envCrossplaneGroups = "TRIAGENT_MCP_CROSSPLANE_GROUPS" + envSessionDir = "TRIAGENT_MCP_SESSION_DIR" + envUserPlaybooksDir = "TRIAGENT_MCP_USER_PLAYBOOKS_DIR" + envPluginPlaybooksDir = "TRIAGENT_MCP_PLUGIN_PLAYBOOKS_DIR" + envSystemPlaybooksDir = "TRIAGENT_MCP_SYSTEM_PLAYBOOKS_DIR" + envStrategiesSubagentModel = "TRIAGENT_MCP_STRATEGIES_SUBAGENT_MODEL" + envMCPConfigPath = "TRIAGENT_MCP_CONFIG_PATH" + envGitRepo = "TRIAGENT_MCP_GIT_REPO" + envGitCacheDir = "TRIAGENT_MCP_GIT_CACHE_DIR" + envGitClaudeBinary = "TRIAGENT_MCP_GIT_CLAUDE_BINARY" + envGitFilterPrereleases = "TRIAGENT_MCP_GIT_FILTER_PRERELEASES" + envWikiServePath = "TRIAGENT_MCP_WIKI_PATH" + envWikiServeProposalsPath = "TRIAGENT_MCP_WIKI_PROPOSALS_PATH" + envWikiServeClaudeBinary = "TRIAGENT_MCP_WIKI_CLAUDE_BINARY" envSessionsProposalsPath = "TRIAGENT_MCP_SESSIONS_PROPOSALS_PATH" envSessionsClaudeBinary = "TRIAGENT_MCP_SESSIONS_CLAUDE_BINARY" @@ -71,10 +76,10 @@ type serveFlags struct { systemPlaybooksDir string // git flags - gitRepo string - gitCacheDir string - gitClaudeBinary string - gitFilterPrereleases bool + gitRepo string + gitCacheDir string + gitClaudeBinary string + gitFilterPrereleases bool // wiki flags wikiPath string @@ -95,6 +100,9 @@ type serveFlags struct { promURL string promBearer string promBasic string + + // cloud flags + cloudProvider string } func serveCmd() *cobra.Command { @@ -104,14 +112,14 @@ func serveCmd() *cobra.Command { Short: "Run one of the triagent-mcp MCP servers over stdio", Long: "Run one of the triagent-mcp MCP servers over stdio.\n\n" + "Select the server via --kind. Supported kinds:\n" + - " k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom", + " k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud", Hidden: true, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runServe(cmd.Context(), resolveFlags(f)) }, } - cmd.Flags().StringVar(&f.kind, "kind", "", "which MCP server to run: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, or prom (required)") + cmd.Flags().StringVar(&f.kind, "kind", "", "which MCP server to run: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, or cloud (required)") // k8s flags cmd.Flags().StringVar(&f.kubeconfig, "kubeconfig", "", "path to kubeconfig (defaults to $"+envKubeconfig+", then $KUBECONFIG, then ~/.kube/config) [kind=k8s]") @@ -150,6 +158,9 @@ func serveCmd() *cobra.Command { cmd.Flags().StringVar(&f.promBearer, "prom-bearer", "", "Authorization: Bearer token for Prometheus (defaults to $"+envPromBearer+") [kind=prom]") cmd.Flags().StringVar(&f.promBasic, "prom-basic", "", "Basic auth credentials user:pass for Prometheus (defaults to $"+envPromBasic+") [kind=prom]") + // cloud flags + cmd.Flags().StringVar(&f.cloudProvider, "provider", "", "cloud provider to serve: gcp or aws; required (defaults to $"+cloud.EnvProvider+") [kind=cloud]") + return cmd } @@ -215,6 +226,9 @@ func resolveFlags(f *serveFlags) serveFlags { if out.promBasic == "" { out.promBasic = os.Getenv(envPromBasic) } + if out.cloudProvider == "" { + out.cloudProvider = os.Getenv(cloud.EnvProvider) + } // Bool env override: only consider when the operator hasn't passed // the flag explicitly. Cobra preserves the flag default (true) when // unset, so we can't distinguish "operator passed --filter-prereleases=true" @@ -263,10 +277,12 @@ func runServe(ctx context.Context, f serveFlags) error { return runParallel(ctx, f) case "prom": return runProm(ctx, f) + case "cloud": + return runCloud(ctx, f) case "": - return fmt.Errorf("--kind is required (one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom)") + return fmt.Errorf("--kind is required (one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud)") default: - return fmt.Errorf("unknown --kind %q (want one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom)", f.kind) + return fmt.Errorf("unknown --kind %q (want one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud)", f.kind) } } @@ -423,6 +439,115 @@ func runProm(ctx context.Context, f serveFlags) error { return srv.Run(ctx) } +// runCloud wires the read-only cloud-context MCP. --provider selects the +// concrete backend; New plugs it in behind cloud.Provider. The launcher passes +// the allowlist override path, target scope, and pinned identity through the +// subprocess env (cloud.EnvAllowlistPath, cloud.EnvScope, +// cloud.EnvExpectedIdentity), never argv. An aws source carries its accounts, +// source_profile, and alias (cloud.EnvAWSAccounts, _SOURCE_PROFILE, _ALIAS); New +// generates the per-account assume-role profiles and surfaces the accounts as the +// agent's selectable targets (a single-account source is a one-entry list). +func runCloud(ctx context.Context, f serveFlags) error { + if f.cloudProvider == "" { + return fmt.Errorf("--provider is required (gcp or aws) (set --provider or $%s)", cloud.EnvProvider) + } + scope, err := parseCloudScope(os.Getenv(cloud.EnvScope)) + if err != nil { + return fmt.Errorf("build cloud mcp server: %w", err) + } + accounts, err := parseAWSAccounts(os.Getenv(cloud.EnvAWSAccounts)) + if err != nil { + return fmt.Errorf("build cloud mcp server: %w", err) + } + gcpProjects, err := parseGCPProjects(os.Getenv(cloud.EnvGCPProjects)) + if err != nil { + return fmt.Errorf("build cloud mcp server: %w", err) + } + provider, err := providers.New(f.cloudProvider, providers.Options{ + AWSAlias: os.Getenv(cloud.EnvAWSAlias), + AWSSourceProfile: os.Getenv(cloud.EnvAWSSourceProfile), + AWSAccounts: accounts, + GCPProjects: gcpProjects, + AWSConfigTarget: os.Getenv(cloud.EnvAWSConfigFile), + AWSConfigSource: os.Getenv(cloud.EnvAWSSourceConfig), + }) + if err != nil { + return err + } + srv, err := cloud.New(cloud.Options{ + Provider: provider, + AllowlistPath: os.Getenv(cloud.EnvAllowlistPath), + Scope: scope, + ExpectedIdentity: os.Getenv(cloud.EnvExpectedIdentity), + }) + if err != nil { + return fmt.Errorf("build cloud mcp server: %w", err) + } + log.Info("mcp serve --kind=cloud starting", "provider", f.cloudProvider) + return srv.Run(ctx) +} + +// parseCloudScope decodes the JSON-encoded target scope the launcher froze into +// a cloud.ScopeAllowlist. An empty value yields an empty scope, which leaves the +// target axes unconstrained. A malformed value is an error that aborts startup: +// failing closed, since a misconfigured scope must never silently widen run_cli +// by dropping the deployment's restrictions. +func parseCloudScope(raw string) (cloud.ScopeAllowlist, error) { + var scope cloud.ScopeAllowlist + if raw == "" { + return scope, nil + } + if err := json.Unmarshal([]byte(raw), &scope); err != nil { + return cloud.ScopeAllowlist{}, fmt.Errorf("malformed cloud scope in $%s: %w", cloud.EnvScope, err) + } + return scope, nil +} + +// parseAWSAccounts decodes the JSON-encoded aws multi-account set the launcher +// froze into the aws provider's account list. An empty value yields nil, the +// single-account / single-identity form. A malformed value is an error that +// aborts startup: failing closed, since a misconfigured accounts list must never +// silently drop accounts the agent should be able to select. +func parseAWSAccounts(raw string) ([]aws.Account, error) { + if raw == "" { + return nil, nil + } + var wire []struct { + AccountID string `json:"account_id"` + RoleARN string `json:"role_arn"` + Tags []string `json:"tags"` + } + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return nil, fmt.Errorf("malformed cloud aws accounts in $%s: %w", cloud.EnvAWSAccounts, err) + } + accounts := make([]aws.Account, 0, len(wire)) + for _, w := range wire { + accounts = append(accounts, aws.Account{ID: w.AccountID, RoleARN: w.RoleARN, Tags: w.Tags}) + } + return accounts, nil +} + +// parseGCPProjects decodes the JSON-encoded gcp project set the launcher froze +// into []gcp.Project. Empty yields nil (the unconstrained form). A malformed +// value aborts startup, failing closed like parseAWSAccounts. +func parseGCPProjects(raw string) ([]gcp.Project, error) { + if raw == "" { + return nil, nil + } + var wire []struct { + ID string `json:"id"` + Tags []string `json:"tags"` + } + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return nil, fmt.Errorf("malformed cloud gcp projects in $%s: %w", cloud.EnvGCPProjects, err) + } + projects := make([]gcp.Project, 0, len(wire)) + for _, w := range wire { + projects = append(projects, gcp.Project{ID: w.ID, Tags: w.Tags}) + } + return projects, nil +} + func runGit(ctx context.Context, f serveFlags) error { if f.gitRepo == "" { return fmt.Errorf("--repo is required (owner/name) (set --repo or $%s)", envGitRepo) diff --git a/cmd/triagent-mcp/serve_cloud_test.go b/cmd/triagent-mcp/serve_cloud_test.go new file mode 100644 index 00000000..5dfa043a --- /dev/null +++ b/cmd/triagent-mcp/serve_cloud_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunServe_CloudKindRequiresProvider(t *testing.T) { + t.Parallel() + err := runServe(context.Background(), serveFlags{kind: "cloud"}) + require.Error(t, err, "expected error when --provider is missing") + assert.Contains(t, err.Error(), "provider", "error should mention --provider") +} + +func TestRunServe_CloudKindRejectsUnknownProvider(t *testing.T) { + t.Parallel() + err := runServe(context.Background(), serveFlags{kind: "cloud", cloudProvider: "azure"}) + require.Error(t, err, "expected error for an unknown provider") + assert.Contains(t, err.Error(), "azure", "error should name the rejected provider") +} + +func TestRunServe_UnknownKindErrorListsCloud(t *testing.T) { + t.Parallel() + err := runServe(context.Background(), serveFlags{kind: "bogus"}) + require.Error(t, err, "expected error for unknown kind") + assert.Contains(t, err.Error(), "cloud", "kind list should include cloud") +} + +func TestServeCmd_KnowsCloudKind(t *testing.T) { + t.Parallel() + cmd := serveCmd() + assert.Contains(t, cmd.Long, "cloud", "serve --help should list cloud") +} + +func TestParseCloudScope_EmptyYieldsUnconstrained(t *testing.T) { + t.Parallel() + scope, err := parseCloudScope("") + require.NoError(t, err) + assert.Empty(t, scope.Regions) + assert.Empty(t, scope.Accounts) +} + +func TestParseCloudScope_ValidJSON(t *testing.T) { + t.Parallel() + scope, err := parseCloudScope(`{"regions":["us-central1"],"accounts":["123456789012"]}`) + require.NoError(t, err) + assert.Equal(t, []string{"us-central1"}, scope.Regions) + assert.Equal(t, []string{"123456789012"}, scope.Accounts) +} + +func TestParseGCPProjects_EmptyYieldsNil(t *testing.T) { + t.Parallel() + got, err := parseGCPProjects("") + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestParseGCPProjects_DecodesIDsAndTags(t *testing.T) { + t.Parallel() + got, err := parseGCPProjects(`[{"id":"prod-a","tags":["prod","payments"]},{"id":"prod-b"}]`) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "prod-a", got[0].ID) + assert.Equal(t, []string{"prod", "payments"}, got[0].Tags) + assert.Equal(t, "prod-b", got[1].ID) + assert.Empty(t, got[1].Tags) +} + +func TestParseGCPProjects_MalformedFailsClosed(t *testing.T) { + t.Parallel() + _, err := parseGCPProjects(`[{"id":`) + require.Error(t, err) +} + +func TestParseCloudScope_MalformedFailsClosed(t *testing.T) { + t.Parallel() + _, err := parseCloudScope(`{"projects":`) + require.Error(t, err, "a malformed scope must fail closed, not silently drop restrictions") +} + +func TestRunCloud_MalformedScopeAborts(t *testing.T) { + t.Setenv("TRIAGENT_CLOUD_PROVIDER", "gcp") + t.Setenv("TRIAGENT_CLOUD_SCOPE", `{"projects":`) + err := runCloud(context.Background(), serveFlags{kind: "cloud", cloudProvider: "gcp"}) + require.Error(t, err, "a malformed scope must abort cloud-server startup") + assert.Contains(t, err.Error(), "scope", "the error should name the scope") +} + +func TestParseAWSAccounts_EmptyYieldsNil(t *testing.T) { + t.Parallel() + accs, err := parseAWSAccounts("") + require.NoError(t, err) + assert.Nil(t, accs) +} + +func TestParseAWSAccounts_DecodesJSON(t *testing.T) { + t.Parallel() + accs, err := parseAWSAccounts(`[{"account_id":"111111111111","role_arn":"arn:aws:iam::111111111111:role/r"},{"account_id":"222222222222","role_arn":"arn:aws:iam::222222222222:role/r"}]`) + require.NoError(t, err) + require.Len(t, accs, 2) + assert.Equal(t, "111111111111", accs[0].ID) + assert.Equal(t, "arn:aws:iam::222222222222:role/r", accs[1].RoleARN) +} + +func TestParseAWSAccounts_MalformedFailsClosed(t *testing.T) { + t.Parallel() + _, err := parseAWSAccounts(`[{"account_id":`) + require.Error(t, err, "a malformed accounts list must fail closed, not silently drop accounts") +} + +func TestRunCloud_MalformedAWSAccountsAborts(t *testing.T) { + t.Setenv("TRIAGENT_CLOUD_PROVIDER", "aws") + t.Setenv("TRIAGENT_CLOUD_AWS_ACCOUNTS", `[{"account_id":`) + err := runCloud(context.Background(), serveFlags{kind: "cloud", cloudProvider: "aws"}) + require.Error(t, err, "a malformed accounts list must abort cloud-server startup") + assert.Contains(t, err.Error(), "accounts", "the error should name the accounts") +} diff --git a/cmd/triagent/start.go b/cmd/triagent/start.go index daaf60d3..56fe4fc9 100644 --- a/cmd/triagent/start.go +++ b/cmd/triagent/start.go @@ -200,6 +200,7 @@ func warnLegacyUnnamespacedDirs(profileName string, paths profile.Paths) { {"sessions-root", paths.SessionsRoot}, {"user-playbooks", paths.UserPlaybooksDir}, {"git-cache", paths.GitCacheDir}, + {"cloud-cache", paths.CloudCacheDir}, } for _, e := range entries { if e.current == "" || !strings.Contains(e.current, seg) { @@ -249,6 +250,7 @@ func runWeb(ctx context.Context, mcpBin string, paths profile.Paths, playbooksRe DocsServerName: docsServerName, UserReposPath: paths.UserReposFile, GitCacheDir: paths.GitCacheDir, + CloudCacheDir: paths.CloudCacheDir, UserPlaybooksDir: paths.UserPlaybooksDir, PluginPlaybooksDir: joinSubpath(paths.UpstreamPlaybooksDir, prof.Defaults.PlaybooksPath), PluginPlaybooksCloneRoot: paths.UpstreamPlaybooksDir, diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md new file mode 100644 index 00000000..ac47a889 --- /dev/null +++ b/docs/content/cloud-providers.md @@ -0,0 +1,434 @@ +# Cloud providers + +Triagent optionally gives the agent read-only context from the cloud the cluster sits on, GCP or AWS, so a Kubernetes investigation can follow a thread down into the cloud layer without a human leaving the loop. It is opt-in and configured entirely in the deployment profile: the core investigation flow (Kubernetes triage, playbooks, wiki) works without it. + +Enable it when your clusters run on GKE or EKS and incidents routinely reach into cloud networking, IAM, logs, or the change-audit trail. Skip it when triage stays inside the cluster. + +## What the cloud-context MCP gives the agent + +A managed-Kubernetes incident is often only explicable from cloud context. A Pod cannot reach a dependency because of a firewall rule or a security group. A workload is denied because an identity lost a binding. The GKE or EKS cluster behaves unexpectedly because of how its networking or workload identity is configured. The smoking gun is in cloud logs, and "what changed right before this broke?" lives in the cloud audit trail, not in the cluster. + +When a cloud source is configured, the launcher registers a `triagent-cloud-` MCP server for each investigation session. The agent reads cloud context along six axes: + +| Axis | What it reads | GCP example | AWS example | +| --- | --- | --- | --- | +| inventory | projects/accounts and resources in view | `compute instances list` | `ec2 describe-instances` | +| reachability | VPCs, subnets, firewall rules, security groups, routes | `compute firewall-rules list` | `ec2 describe-security-groups` | +| permissions | IAM policies, roles, service accounts | `projects get-iam-policy` | `iam list-roles` | +| cluster | GKE/EKS networking and node config | `container clusters describe` | `eks describe-cluster` | +| logs | cloud logs | `logging read` | `logs filter-log-events` | +| audit | change history ("what changed before this broke") | `logging read … activity` | `cloudtrail lookup-events` | + +In practice, chasing a Pod that cannot reach its database, the agent calls `list_inventory` to see the configured projects or accounts, `set_active_target` to pin the right one, then `run_cli` with `ec2 describe-security-groups` (or `compute firewall-rules list`) to find a rule blocking the path and `cloudtrail lookup-events` (or `logging read … activity`) to see which change introduced it and when. Every call is read-only. + +The MCP is read-only by construction, not by convention. The agent supplies argument tokens to a fixed `gcloud` or `aws` binary that runs without a shell, against a positive command allowlist with a hardcoded deny floor underneath, as a pinned read-only identity it can neither select nor escalate. Three independent layers each have to hold for a read to go through, and none of them can be widened by the agent: + +```mermaid +flowchart LR + A["agent: run_cli argv"] --> B{"Gate 1: command allowlist
leaf-verb prefix match"} + B -->|not listed| X["denied at harness"] + B -->|listed| C{"Gate 2: deny floor
subcommands, flags, file/url args"} + C -->|hits floor| X + C -->|clean| D{"Gate 3: read-only IAM
on the pinned identity"} + D -->|no permission| Y["denied at cloud"] + D -->|allowed| Z["read returns"] +``` + +Gates 1 and 2 are enforced by the harness before the binary runs; Gate 3 is enforced by the cloud itself. A misconfigured-too-broad allowlist still cannot read secrets or write, because Gate 3 is the identity's own permissions. + +Stated as a capability boundary, what an adversarial or compromised agent cannot do, and what stops each: + +| The agent cannot… | Stopped by | Hard or soft | +| --- | --- | --- | +| write or mutate anything | Gate 3: read-only IAM on the pinned identity | hard | +| read secret values, object contents, or decrypted material | Gate 2 deny floor, backed by Gate 3 IAM | hard | +| exfiltrate via local files or URLs (`file://`, `@`, `http://`) | Gate 2 deny floor on argument values | hard | +| select, escalate, or authenticate a different identity | pin lives in environment; `--impersonate-service-account` / `--profile` deny-floored | hard | +| pivot to an unconfigured project or account | target-selecting flags deny-floored; reachable set bounded by the identity's IAM | hard | +| leave the allowed regions | `scope.regions` checked on argv | soft | + +The first five hold even under a careless allowlist override, because they rest on the identity's own permissions and the hardcoded floor, neither of which the config can widen. Region scope is the one soft control: it rejects an explicit out-of-region `--region`/`--zone` but does not police an omitted one, so treat it as a guardrail against pivots, not a boundary (see [Scope allowlist](#scope-allowlist)). + +## The pinned identity + +The cloud identity is a deployment-chosen, read-only principal pinned in the profile. The agent can read which identity is active (it has a `session_status` whoami tool) and, when the deployment configures more than one target, switch among that pinned set with `set_active_target` (see [Active target](#active-target-moving-across-projects-and-accounts)) — but it has no tool to name an arbitrary identity, escalate one, or authenticate one. The deployment grants each pinned identity read-only IAM, and that grant is the outermost floor: even a misconfigured-too-broad command allowlist cannot read secrets or exfiltrate, because the identity itself lacks the permission. + +The operator authenticates as themselves through their own normal cloud tooling. The harness then pins impersonation (GCP) or assume-role (AWS) of the configured read-only identity through environment it controls, never through anything the agent can supply. Triagent stores no cloud credential. Re-authentication is the operator's own corporate flow, outside Triagent. + +## Before you start + +You need: + +- The provider CLI on the launcher host: `gcloud` for a GCP source, `aws` (v2) for an AWS source. Triagent shells these directly. +- A read-only identity to pin, created by whoever administers the cloud: a service account (GCP) or one IAM role per account (AWS). This is a one-time admin step. +- Permission for the operator to assume that identity: `roles/iam.serviceAccountTokenCreator` on the service account (GCP), or a role trust policy naming the operator's principal (AWS). + +Setup has the same shape for both clouds: + +1. Authenticate as yourself (`gcloud auth login` / `aws sso login`). +2. Create the read-only identity and grant it the read-only roles (per provider, below). +3. Add a `cloud:` entry to the deployment profile pinning that identity. +4. Restart the launcher, then open the connections panel: the source shows valid once its identity probe succeeds. + +The `cloud:` block lives in the deployment profile (`profile.yaml`); see [Profiles](/docs/profiles) for where that file is and how it is structured. The launcher reads the profile once at startup, so restart it after editing. + +## GCP setup + +The operator authenticates normally: + +```sh +gcloud auth login +``` + +The deployment grants the operator `roles/iam.serviceAccountTokenCreator` on a read-only service account. This is a one-time admin step, and the price of not storing a secret: the operator's own login plus the impersonated service account gives a clean audit trail (human plus role). + +That binding lets the operator *act as* the service account; it is separate from what the service account itself may *read*. Create the account, grant yourself impersonation on it, then grant it read-only access on each project in the source's scope. + +The account lives in a host project: the one encoded in its email, `…@.iam.gserviceaccount.com`. The host project owns the account resource and is not necessarily a project the agent reads. The projects the agent reads are the ones you bind roles on, which can be the host project, a different set, or both. + +```sh +# Create the read-only service account once, in a host project of your choice. +# Here the host project is `prod`, giving triage-readonly@prod.iam.gserviceaccount.com. +gcloud iam service-accounts create triage-readonly \ + --project=prod \ + --display-name="Triagent read-only cloud context" + +SA=triage-readonly@prod.iam.gserviceaccount.com +OPERATOR=you@example.com # the human who runs Triagent + +# Let the operator impersonate the account. This binding is ON THE SERVICE +# ACCOUNT resource (note: `iam service-accounts add-iam-policy-binding`, not +# `projects ...`), and is what `gcloud auth login` plus the pin below exercise. +# Without it, impersonation fails with PERMISSION_DENIED on +# iam.serviceAccounts.getAccessToken. Needs serviceAccountAdmin/owner to run. +gcloud iam service-accounts add-iam-policy-binding "$SA" \ + --member="user:$OPERATOR" \ + --role="roles/iam.serviceAccountTokenCreator" + +# Grant the minimal read-only roles covering the default tool surface +# (inventory, reachability, IAM read, GKE, logs, audit) on EACH project in the +# source's scope. These bindings are ON EACH PROJECT (`projects ...`), not on +# the service account, and the target projects are independent of the host +# project above. +for project in prod-platform prod-data; do + for role in \ + roles/browser \ + roles/compute.viewer \ + roles/container.viewer \ + roles/iam.securityReviewer \ + roles/logging.viewer \ + roles/monitoring.viewer; do + gcloud projects add-iam-policy-binding "$project" \ + --member="serviceAccount:$SA" --role="$role" + done +done + +# Verify the operator can impersonate the account: prints a token, not an error. +gcloud auth print-access-token --impersonate-service-account="$SA" +``` + +`roles/browser` lists and reads projects, `compute.viewer` and `container.viewer` cover networking and GKE, `iam.securityReviewer` reads IAM policies and service accounts, and the logging and monitoring viewers cover the logs and audit axes. If you would rather not curate, the single basic role `roles/viewer` is read-only across all of these and is the simpler, broader alternative. Role names are current as of writing; verify against GCP's IAM reference, which evolves. + +The profile pins that service account as `assumed_identity` and lists the projects the agent may select among as `projects`: + +```yaml +cloud: + - alias: prod-gcp + provider: gcp + assumed_identity: triage-readonly@prod.iam.gserviceaccount.com # the impersonated read-only SA + projects: + - {id: prod-platform} + - {id: prod-data} +``` + +The harness sets `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=` on the cloud MCP subprocess, so every `gcloud` call runs as the pinned service account while authenticating from the operator's base credentials. The agent never picks the identity, and because the pin lives in environment rather than in argv, `--impersonate-service-account` stays on the agent's deny floor without contradiction. + +The whoami probe reports the source valid when impersonation is pinned to the configured service account and a minimal impersonated token read succeeds, proving the pin took effect. Under impersonation the operator's own base account stays active, so the probe does not require the active `gcloud` account to equal the service account; it confirms the pin and the read instead. + +## AWS setup + +The operator authenticates normally, for example: + +```sh +aws sso login +``` + +An AWS role lives in exactly one account, so a source names a list of `accounts` — one `{account_id, role_arn}` per account the agent may reach — plus the operator's SSO base as `source_profile`. A single-account source is simply a one-entry list; there is no separate single-account shape. You do not pre-create an assume-role profile per account: triagent generates one read-only assume-role profile per entry at session start, layering each account's `role_arn` over `source_profile`. It writes these into its own per-profile config, never your `~/.aws/config` (see below). + +```yaml +cloud: + - alias: prod-aws + provider: aws + source_profile: default # the operator's SSO base + accounts: + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly"} +``` + +An AWS source has no `assumed_identity` — its identity is per-account, in each `accounts` entry's `role_arn`. The harness sets `AWS_PROFILE` to the active account's generated profile on each `run_cli`, so the AWS CLI assumes that account's read-only role from the operator's base credentials. The pin lives in environment, so `--profile` stays on the agent's deny floor. The connections panel validates the default (first) account's role. + +Each account's read-only role needs a permission policy and a trust policy. The minimal permission policy, scoped to exactly the default tool surface: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "TriageReadOnly", + "Effect": "Allow", + "Action": [ + "sts:GetCallerIdentity", + "organizations:ListAccounts", + "organizations:DescribeOrganization", + "ec2:Describe*", + "iam:GetRole", "iam:ListRoles", + "iam:ListAttachedRolePolicies", "iam:ListRolePolicies", "iam:GetRolePolicy", + "iam:GetPolicy", "iam:GetPolicyVersion", "iam:ListPolicies", + "iam:SimulatePrincipalPolicy", + "eks:ListClusters", "eks:DescribeCluster", + "eks:ListNodegroups", "eks:DescribeNodegroup", + "eks:ListFargateProfiles", "eks:DescribeFargateProfile", + "logs:DescribeLogGroups", "logs:DescribeLogStreams", + "logs:FilterLogEvents", "logs:GetLogEvents", + "cloudtrail:LookupEvents", "cloudtrail:DescribeTrails", "cloudtrail:GetTrailStatus" + ], + "Resource": "*" + } + ] +} +``` + +The trust policy names the principal allowed to assume the role. That principal is whatever your `source_profile` authenticates as. In this single-account example the operator's SSO identity and the role both live in `123456789012`, so the account's own root works: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::123456789012:root" }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +Scope the `Principal` down from the account root where you can. With AWS IAM Identity Center, name the SSO permission-set role your operators log in as: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::555500000000:role/aws-reserved/sso.amazonaws.com/AWSReservedSSO_PlatformOps_0123456789abcdef" + }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +Here `555500000000` is the account your SSO login resolves into, which need not be the account the role lives in. That distinction is what makes multi-account work, below. For cross-account trust, harden it further with a `Condition` rather than relying on the principal ARN alone: an `sts:ExternalId` agreed between the accounts, or an `aws:PrincipalOrgID` restricting the trust to your organization, narrows who may assume the role beyond naming the permission set. If you would rather not curate the permission policy, the AWS-managed `ReadOnlyAccess` policy is the broader, simpler alternative. Action names are current as of writing; verify against AWS's service-authorization reference, which evolves. + +Save the permission policy as `permission-policy.json` and your chosen trust policy as `trust-policy.json`, then create the role, attach the read-only permissions, and confirm you can assume it: + +```sh +ROLE=triage-readonly +ACCOUNT=123456789012 + +# Create the role with its trust policy (who may assume it). +aws iam create-role \ + --role-name "$ROLE" \ + --assume-role-policy-document file://trust-policy.json + +# Attach the least-privilege read-only permissions inline. +aws iam put-role-policy \ + --role-name "$ROLE" \ + --policy-name TriageReadOnly \ + --policy-document file://permission-policy.json +# Or, instead of the inline policy, attach the AWS-managed alternative: +# aws iam attach-role-policy --role-name "$ROLE" \ +# --policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess + +# Verify your SSO base can assume it: prints the assumed-role ARN, not an error. +aws sts assume-role \ + --role-arn "arn:aws:iam::$ACCOUNT:role/$ROLE" \ + --role-session-name triage-verify \ + --query AssumedRoleUser.Arn --output text +``` + +For several accounts, repeat the create-and-attach per account, each role trusting the same SSO identity (see [Spanning several AWS accounts](#spanning-several-aws-accounts)). + +The whoami probe resolves the active caller with `aws sts get-caller-identity`. It reports valid when the caller is an assumed-role ARN whose underlying role matches the active account's `role_arn`. A plain user or root ARN means the assume-role pin did not take effect and base credentials leaked through, so the source degrades. + +### Spanning several AWS accounts + +An IAM role lives in exactly one account, so reaching several accounts is just a longer `accounts` list — one read-only `role_arn` per account, each with the account id the agent selects by. The same `source_profile` (the operator's SSO base) backs every generated profile. + +```yaml +cloud: + - alias: prod-aws + provider: aws + source_profile: sso-admin # the operator's SSO base profile + accounts: + - {account_id: "111111111111", role_arn: "arn:aws:iam::111111111111:role/triage-readonly"} + - {account_id: "222222222222", role_arn: "arn:aws:iam::222222222222:role/triage-readonly"} + - {account_id: "333333333333", role_arn: "arn:aws:iam::333333333333:role/triage-readonly"} +``` + +Triagent never edits your `~/.aws/config`. It generates a triagent-owned config under `${XDG_CACHE_HOME}/triagent-mcp//aws/config`: a copy of your `~/.aws/config` (so `source_profile` resolves) followed by the generated assume-role profiles, pointed at the cloud MCP with `AWS_CONFIG_FILE`. The file is per deployment profile and rewritten idempotently; your own config is only ever read. triagent stores no credential, the AWS CLI performs the assume-role from your SSO base. + +Give every account's role the same read-only permission policy. The trust policy needs care across accounts: each role's `Principal` must be the identity your `source_profile` authenticates as, which for cross-account reach is a *different* account than the role lives in. Point all of them at that one SSO identity (the IAM Identity Center example above); do not copy the single-account `:root` principal into each account, or a role will only trust callers from its own account and the assume-role fails. The same SSO base assuming a read-only role in each account is what lets one login span the set. + +The connections panel shows the default (first) account's validity; `session_status` re-probes the active account's own role when the agent switches, so a non-default account reports its own validity in-session. + +## Active target: moving across projects and accounts + +A source can span more than one target — several projects under one GCP identity, or several accounts under an AWS `accounts` list. The agent chooses which one subsequent `run_cli` commands run against with the `set_active_target` tool, naming a target id from `list_inventory` (a project id for GCP, an account id for AWS). `list_inventory` returns each target's deployment-supplied `tags` (e.g. `prod`, `payments`) so the agent can judge which target an investigation belongs to. The agent can select only among the deployment-configured targets; a target outside that set is rejected, and `session_status` reports the active target alongside the pinned identity. + +The two clouds reach their target set by different mechanisms, but configure it the same way — a `{id/account_id, tags}` list per source: + +- **GCP — one identity, many projects.** A single impersonated read-only service account can be granted viewer on every project, so one identity already spans them. Switching target changes only `CLOUDSDK_CORE_PROJECT`; the identity is unchanged, and `session_status` reports the same service account throughout. The selectable set is the source's `projects` list (or, when it is omitted, the projects `list_inventory` surfaces live, untagged). +- **AWS — one account per role.** A role lives in one account, so each account is its own read-only role. The selectable set is the source's `accounts` list, and switching target sets `AWS_PROFILE` to that account's generated profile — a different identity per account, so `session_status` re-probes on switch. + +When a source has exactly one target, it is active from session start and the agent need not choose. When it has several and the agent has not yet chosen, `run_cli` returns an actionable error naming `set_active_target` rather than running against an unintended default. This is also why omitting a target flag is safe under multiple targets: the active target is an in-scope pin, never the CLI's ambient default. + +## The `cloud:` profile block + +Cloud sources live under a top-level `cloud:` list in the profile. Each entry is one provider connection the launcher wires as a `triagent-cloud-` MCP. + +```yaml +# Read-only cloud-context sources. Each entry attaches a +# triagent-cloud- MCP to every investigation session. Identities +# are pinned here, never entered in the connections panel — the agent can +# read the active identity but cannot select or escalate it. +cloud: + - alias: prod-gcp # stable name; the MCP is aliased triagent-cloud-. + provider: gcp # "gcp" | "aws". + # The pinned read-only identity. For gcp, the service-account email the + # harness impersonates via CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT. + assumed_identity: triage-readonly@prod.iam.gserviceaccount.com + # The selectable projects the agent may set_active_target to, each with + # free-form tags list_inventory returns so the agent can judge relevance. + # Omit projects entirely to let the agent select among all projects the SA + # can see (live, untagged). + projects: + - {id: prod-platform, tags: [prod, payments]} + - {id: prod-data, tags: [prod, analytics]} + scope: + regions: [us-central1, us-east1] # --region / --zone enforced on run_cli argv. + # Optional run_cli allowlist override. Empty uses the provider's + # embedded read-only default. + # command_allowlist_path: gcp-commands.json + + - alias: prod-aws + provider: aws + # aws has no assumed_identity — its identity is per-account (each accounts + # entry's role_arn). The connections panel validates the default account. + # aws: the operator's SSO base profile the generated per-account assume-role + # profiles layer their role over. Required for aws; gcp ignores it. + source_profile: sso-admin + # aws: the account set the agent selects among via set_active_target. Each + # entry is {account_id, role_arn, tags}; a single-account source is a + # one-entry list. Tags are returned by list_inventory. + accounts: + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly", tags: [prod, payments]} + scope: + regions: [eu-west-1] # --region / --zone enforced on run_cli argv. + accounts: ["123456789012"] # informational scope note; distinct from the source-level accounts list. +``` + +The fields: + +- `alias` — stable name for the source; the MCP is aliased `triagent-cloud-` and the connections panel keys off it. +- `provider` — `gcp` or `aws`. Selects the concrete provider behind the shared MCP. +- `assumed_identity` — GCP only: the impersonated read-only service-account email, shown in the connections panel and impersonated directly. AWS has no `assumed_identity` (setting it on an AWS source is rejected); its identity is per-account, in each `accounts` entry's `role_arn`. +- `source_profile` — AWS only. The operator's SSO base profile the generated per-account assume-role profiles layer their role over. Required for AWS; GCP ignores it. +- `accounts` — AWS only. The account set the agent selects among via `set_active_target`; each entry is `{account_id, role_arn, tags}`, and a single-account source is a one-entry list. See [Spanning several AWS accounts](#spanning-several-aws-accounts). This is the source-level selectable set, distinct from the informational `scope.accounts` note. +- `projects` — GCP only. The selectable project set, each `{id, tags}`. Optional: when omitted the agent selects among the projects `list_inventory` surfaces live (untagged). +- `tags` — on each `accounts`/`projects` entry: a free-form list of deployment-supplied labels (e.g. `[prod, payments]`) returned by `list_inventory`, so the agent can judge which target an investigation belongs to. Not validated, not security-bearing. +- `scope` — the argv allowlist (see below). +- `command_allowlist_path` — an optional `run_cli` allowlist override (see below). Empty uses the provider's embedded default. + +## Scope allowlist + +`scope` constrains the `run_cli` argv. Only the region/zone axis is enforced here: a `--region`/`--zone` value outside it fails validation before the command runs. The selectable project (GCP) / account (AWS) set is not part of `scope` — it is the source's `projects`/`accounts` list, chosen via `set_active_target` — and the target-selecting flags (`--project`, `--account`, `--profile`) are deny-floored, so the agent cannot pivot through argv. + +```yaml +scope: + regions: [us-central1] # --region / --zone values the agent may use (argv-enforced) + accounts: ["123456789012"] # aws accounts reachable via the pinned role (informational) +``` + +An empty (or omitted) `regions` axis is unconstrained; a non-empty one is a closed set enforced before the command runs. + +The active target is the effective default, so a command that omits the target flag runs against the active target rather than an ambient one (`CLOUDSDK_CORE_PROJECT` for GCP, the active account's profile for AWS). Region has no active-target equivalent: an omitted `--region` falls back to the configured `AWS_REGION` / gcloud default, which scope does not police. Hard project confinement comes from the pinned identity's IAM, not from scope: grant the read-only roles only on the projects you list, so an out-of-scope project is unreachable whatever the argv. Treat region scope as a guardrail against explicit pivots rather than a hard limit. + +`accounts` is informational and reserved: it documents which AWS accounts the source is expected to reach, but `run_cli` does not validate account ids on argv. What actually bounds account reach is the pinned assume-role profile, whose role can only see the accounts its trust policy and permissions allow. Treat `accounts` as a note to operators, not an enforced allowlist. + +Identity- and target-selecting flags (`--account`, `--profile`, `--project`) never reach scope validation at all, because the deny floor rejects them first. + +## Command allowlist + +What the agent can run through `run_cli` is governed by a positive command allowlist of normalized subcommand paths, for example `compute firewall-rules list` for GCP or `ec2 describe-security-groups` for AWS. Each provider ships an embedded read-only default covering the six axes. Point `command_allowlist_path` at a file (relative to the profile.yaml) to override it; an empty value uses the embedded default. The allowlist is the single source of truth, so the discovery tool advertises exactly what is permitted. + +An allowlist is a flat list of `{path, description}` entries, grouped by axis. A few lines from the AWS default: + +```json +{ + "commands": [ + { "path": "ec2 describe-instances", "description": "inventory: list EC2 instances and their state/placement" }, + { "path": "ec2 describe-security-groups", "description": "reachability: inspect security-group ingress/egress rules" }, + { "path": "iam list-roles", "description": "permissions: enumerate IAM roles in the account" }, + { "path": "eks describe-cluster", "description": "cluster: read EKS cluster networking and config" }, + { "path": "logs filter-log-events", "description": "logs: read CloudWatch log events filtered by pattern/time" }, + { "path": "cloudtrail lookup-events", "description": "audit: read recent management-event history from CloudTrail" } + ] +} +``` + +Allowlist entries must be complete leaf verbs, never an intermediate group path. The allowlist matches an entry as a prefix of the command, so a group-path entry would also admit its sibling verbs, including mutating ones: + +| Entry | Prefix-matches | Verdict | +| --- | --- | --- | +| `ec2 describe-security-groups` | that one verb | ✅ leaf read | +| `ec2` | `ec2 terminate-instances`, every verb | ❌ admits mutating siblings | +| `compute instances list` | that one verb | ✅ leaf read | +| `compute instances` | `compute instances delete` | ❌ admits mutating siblings | + +The shipped defaults are all leaf read verbs. The guarantee that the agent cannot write, even under a careless override, is the read-only IAM grant on the pinned identity (Gate 3): a viewer-only principal's mutating call fails at the cloud. The allowlist and deny floor keep the agent to reads and exclude secret-read and exfil; the no-write property itself rests on the identity's permissions. + +Underneath the allowlist sits a hardcoded deny floor the config can never re-enable, mirroring how the k8s MCP always filters Secret regardless of its kinds config. A too-broad allowlist override cannot punch through it. The floor covers three categories: + +| Category | What it rejects | +| --- | --- | +| dangerous subcommands | `secrets`, `ssh`, `scp`, `cp`, `sync`, `auth`, `config` | +| dangerous flags | `--impersonate-service-account`, `--account`, `--profile`, `--endpoint-url`, `--cli-input-json`, `--cli-input-yaml`, `--configuration` | +| argument-value prefixes | values beginning with `file://`, `fileb://`, `@`, `http://`, `https://` (local-file read and SSRF vectors) | + +The command allowlist and the IAM grant are independent layers and must stay aligned. The recommended policies above are least-privilege for the default allowlist. Tightening the allowlist needs no IAM change; if you widen it with `command_allowlist_path`, widen the identity's read-only grant to match, or the added commands fail at the cloud rather than at the harness. Never widen either beyond read-only. The authoritative list of what a configured source permits is whatever the agent's `list_allowed_commands` tool returns, which reads the same allowlist `run_cli` enforces; each provider's shipped default lives in its `default_commands.json` under `pkg/mcp/cloud/providers//`. + +## Verifying and troubleshooting + +A source is working when the connections panel shows it valid: the whoami probe confirmed the pin took effect (impersonation for GCP, an assumed-role ARN matching the account's `role_arn` for AWS). The probe runs on connections-panel load, so you confirm a source before starting a session rather than discovering a degraded one mid-investigation. + +A stale or invalid cloud credential never blocks Kubernetes triage. Unlike the cluster-auth preflight, which gates the session, a failed cloud probe degrades only that source: the panel marks it unavailable with a re-auth hint, the session starts with that source disabled, and the Kubernetes investigation proceeds without the cloud axis. + +SSO and assume-role sessions expire on their own schedule, so a long investigation can outlast them and a source that started healthy can go unavailable mid-session. The fix is the same operator re-auth (step 1 below); subsequent reads pick up the refreshed credentials. + +If a source shows unavailable, check in order: + +1. **Authentication.** Re-run your own cloud login (`gcloud auth login`, `aws sso login`). An expired credential is the most common cause, and the only fix that lives with the operator rather than the deployment. +2. **The resolved identity.** For AWS, a plain user or root ARN instead of an assumed-role ARN means the assume-role pin did not take effect and base credentials leaked through: check `source_profile` names your SSO base. For GCP, a failed pin means impersonation did not resolve: check `roles/iam.serviceAccountTokenCreator` is granted to you on the service account. +3. **The read grant.** Confirm the service account (GCP) or role (AWS) actually carries the read-only roles on the target project or account. +4. **The trust policy (AWS).** Confirm each role trusts the principal your `source_profile` authenticates as. A `Principal` pointing at the role's own account instead of your SSO base is the usual cross-account failure. +5. **Allowlist vs. IAM.** If you widened the allowlist, the added commands fail at the cloud unless you widened the identity's grant to match. + +Two records show what the agent did. The investigation transcript captures every tool call, including the exact `run_cli` argv, so the agent's cloud reads are reviewable in-session. Cloud-side, because every call runs under the pinned identity, your provider's audit log (CloudTrail, Cloud Logging) records them under the assumed role or impersonated service account, giving the human-plus-role trail. + +## See also + +- [Connections](/docs/connections). Slack and incident.io credential handling, and the read-only cloud pills the same panel surfaces. +- [Profiles](/docs/profiles). The deployment config bundle the `cloud:` block lives in. +- [MCP](/docs/mcp). The tool catalog the cloud source extends. diff --git a/docs/content/connections.md b/docs/content/connections.md index de124fd2..e42ee961 100644 --- a/docs/content/connections.md +++ b/docs/content/connections.md @@ -61,6 +61,14 @@ When the operator pastes an incident URL in the new-investigation form, the agen from the URL and passes it as `incident_id` to every incident.io tool call. The agent can also look up other incidents by passing a different `incident_id`. +## Cloud (read-only) + +Cloud connections (GCP and AWS) appear in the same panel, but read-only. They are configured in the deployment profile under the `cloud:` block, not entered here, so the panel shows a pill per source with no link or replace affordance. + +Each pill shows the source's pinned identity (the impersonated `assumed_identity` for GCP, or the `source_profile` and account set for AWS) and a validity state. Validity comes from an identity probe run on panel load: GCP confirms impersonation is pinned to the configured service account and proves it with a minimal impersonated token read (your base account stays active under impersonation, so the probe does not require it to match the service account), AWS checks that the resolved caller is the pinned assume-role identity. A source that fails the probe shows unavailable with a re-auth hint, and re-authentication is your own cloud login (`gcloud auth login`, `aws sso login`), never a token entered in Triagent. + +See [Cloud providers](/docs/cloud-providers) for the service-account and assume-role setup, the `cloud:` profile block, and the read-only command surface. + ## Removing a connection Click **disconnect** in the relevant card inside the connections modal. The token is removed from diff --git a/docs/content/profiles.md b/docs/content/profiles.md index 05bd747b..df415788 100644 --- a/docs/content/profiles.md +++ b/docs/content/profiles.md @@ -61,6 +61,20 @@ extra_mcps: - alias: org-docs description: Org-internal docs MCP, hosted via Claude Code. +# Read-only cloud-context sources. Each entry attaches a +# triagent-cloud- MCP so the agent can read GCP / AWS context +# (reachability, IAM, GKE/EKS config, logs, audit) during triage. The +# identity is pinned here, never entered in the UI. See "Cloud sources" +# below and the Cloud providers page for SA / assume-role setup. +cloud: + - alias: prod-gcp + provider: gcp + assumed_identity: triage-readonly@prod.iam.gserviceaccount.com + projects: + - {id: prod-platform, tags: [prod, payments]} + scope: + regions: [us-central1] + # Authentication for cluster access. Two kinds: # kubeconfig — reads $KUBECONFIG / ~/.kube/config. Zero setup. # teleport — SSO via `tsh login`. Requires the teleport block below. @@ -379,10 +393,37 @@ checkouts at the conventional locations under `paths.*` — useful when the team upstream dirs fail fast with a clear error so the operator can pre-seed them manually rather than the launcher silently running in local-only mode. +## Cloud sources + +The `cloud:` block attaches read-only GCP / AWS context MCPs to every investigation, one `triagent-cloud-` per entry. Each source pins a read-only identity the agent can read but never select or escalate — one impersonated service account for GCP (`assumed_identity`), or a per-account assume-role set for AWS (`accounts`, each with its own `role_arn`). Region reach is constrained by `scope.regions`, the only argv-enforced axis; project/account selection is handled by the source's `projects`/`accounts` targets and `set_active_target`, and `scope.accounts` is informational. See [Cloud providers](/docs/cloud-providers) for the precise enforcement model. + +```yaml +cloud: + - alias: prod-gcp + provider: gcp # "gcp" | "aws" + assumed_identity: triage-readonly@prod.iam.gserviceaccount.com # impersonated SA + projects: # selectable projects + tags returned by list_inventory + - {id: prod-platform, tags: [prod, payments]} + scope: + regions: [us-central1] + - alias: prod-aws + provider: aws # aws has no assumed_identity; identity is per-account + source_profile: sso-admin # operator's SSO base profile + accounts: # one {account_id, role_arn, tags} per account; single = one entry + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly", tags: [prod, payments]} + scope: + accounts: ["123456789012"] + regions: [eu-west-1] + # command_allowlist_path: aws-commands.json # override the embedded read-only default +``` + +The identity setup (granting `roles/iam.serviceAccountTokenCreator` on the GCP service account, configuring the AWS assume-role profile) is a one-time deployment step. See [Cloud providers](/docs/cloud-providers) for the full per-provider setup, the field reference, the scope and command allowlists, and the visible-degrade behaviour when a cloud credential is stale. + ## See also - [Connections](/docs/connections). Slack and incident.io credential handling. Credentials live outside the profile, in `~/.config/triagent/credentials.json`. +- [Cloud providers](/docs/cloud-providers). The read-only GCP / AWS context the `cloud:` block configures. - [Repos](/docs/repos). What `linked_repos` enables per repo, including the architecture-summary cache and codefix. - [MCP](/docs/mcp). The tool catalog `extra_mcps` extends. - [`profile.yaml`](https://github.com/sourcehawk/triagent/blob/main/internal/profile/profiles/default/profile.yaml). diff --git a/docs/site/lib/sections.ts b/docs/site/lib/sections.ts index 87200c32..a54f547d 100644 --- a/docs/site/lib/sections.ts +++ b/docs/site/lib/sections.ts @@ -11,6 +11,7 @@ export type SectionID = | "repos" | "wiki" | "connections" + | "cloud-providers" | "profiles"; export type Section = { @@ -60,6 +61,11 @@ export const SECTIONS: Section[] = [ label: "Connections", subtitle: "Slack and incident.io integrations", }, + { + id: "cloud-providers", + label: "Cloud providers", + subtitle: "Read-only GCP and AWS investigation context", + }, { id: "profiles", label: "Profiles", diff --git a/docs/superpowers/specs/2026-06-02-cloud-triage-playbook-design.md b/docs/superpowers/specs/2026-06-02-cloud-triage-playbook-design.md new file mode 100644 index 00000000..e764155d --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-cloud-triage-playbook-design.md @@ -0,0 +1,52 @@ +# Cloud-triage playbook + +## Problem + +The cloud-context MCP gives the agent read-only capability across six axes (inventory, reachability, permissions, cluster, logs, audit) but no *discipline*. Nothing tells the agent when cloud triage is warranted versus a rabbit hole, and nothing orients it before it starts running `run_cli` reads. Two failure modes follow: + +1. **Cloud-spelunking with no signal.** The agent has cloud tools, so it reaches for them even when the symptom is plainly cluster-internal (app bug, k8s misconfig, image, OOM). A read-only identity makes this harmless but wasteful and distracting. +2. **Querying before orienting.** The agent runs cloud reads without first pinning which project/account/region it is even looking at, so the reads target the wrong (or an ambient) scope. + +A guided-flow playbook can encode the judgment a good operator applies: do not enter the cloud without a cloud-shaped signal, and when you do, pin the target before you read. + +## Design + +A new `type: general` sub-flow, `system/cloud_triage.yaml`, modeled on `system/prom_lookup.yaml`. It is binary-embedded (picked up by `system/embed.go`'s `*.yaml` glob), provider-agnostic (the cloud MCP's tools — `list_inventory`, `set_active_target`, `session_status`, `run_cli`, `list_allowed_commands` — are neutral across GCP and AWS), and read-only by construction (the cloud MCP's three gates already bound it). + +### Reachability + +Not wired into the locked `investigation.yaml`. Like `prom_lookup`, the agent reaches it by its own judgment via `list_playbooks` / `playbook_correlate` when it recognizes a cloud-shaped signal, walks it as a sub-flow, and returns. Entry is disciplined by two things: a sharp `symptom`/`description` so `playbook_correlate` only surfaces it for cloud-shaped cases, and an explicit gate node inside. + +### Node graph (6 nodes) + +1. **`gate`** (entrypoint). The restraint mechanism. A cloud-shaped signal is one of: an account id, a role or service-account ARN, a cloud resource name surfaced in evidence; a cloud permission / quota / throttle error; or an explicit user request to check the cloud. If none is present, the symptom is cluster-internal and the playbook does not query the cloud. + - → `orient` when a concrete signal is present (or the user asked). + - → `terminal_no_signal` otherwise. +2. **`orient`**. Pin project/account/region before any read. Derive coordinates from the cluster first: the workload's node carries the cloud account/project and region (`spec.providerID`, `topology.kubernetes.io/region` and `/zone` labels), and the workload's ServiceAccount workload-identity annotation names the cloud identity, all read via `triagent-k8s`. Then `session_status` (what target is pinned) and `list_inventory` (configured targets and tags), and `set_active_target` to the match. + - → `investigate` when the right target is active and the region is known. + - → `terminal_blocked` when no configured target matches the signal's account/project. +3. **`investigate`**. One lean node. Pick the cheapest read along the axis the signal points to: reachability (security groups / firewall rules / routes), permissions (IAM reads, simulate), cluster config (GKE/EKS), or logs/audit (what changed before it broke). Use `list_allowed_commands` if unsure what is permitted; `run_cli` for the read; correlate the finding back to the cluster symptom and timeline. + - → `terminal_done` when a finding explains or cleanly rules out the cluster symptom. + - → `terminal_blocked` when the needed read is outside the allowlist or the identity's grant. +4. **`terminal_done`**. Hand a short citable bullet back to the parent (resource + finding + time, or "cloud ruled out"). It is a sub-flow, so it does not call `summarize`. +5. **`terminal_no_signal`**. Return without searching, stating explicitly that no cloud signal surfaced and the investigation stays in the cluster, so the parent does not re-enter. This node encodes the core restraint principle. +6. **`terminal_blocked`**. Could not complete: either no configured target matches, or the read is outside the allowlist / identity grant. Name which, advise against trying to widen, continue in-cluster. + +### Tool references + +The cloud MCP's wire alias is per-source (`triagent-cloud-`), so unlike `prom_lookup` the cloud tools cannot be hardcoded in `suggested_calls`. Only the stable `triagent-k8s` node read goes in `suggested_calls`; the cloud tools are named in node prose. + +### Entity tags + +`errors` / `symptoms` tags drawn from the `^[a-z0-9-]+$` vocabulary (e.g. `permission-denied`, `forbidden`, `timeout`) so `playbook_correlate` ranks it for cloud-shaped queries. + +## Testing + +`system/embed_test.go` already loads and validates every embedded playbook (parse, terminal nodes carry advice, branch `goto`s resolve within the document). Extend it to assert `cloud_triage` loads and its graph is well-formed (entrypoint `gate` present, every `goto` resolves, terminals carry `terminal_advice`). + +## Out of scope + +- No edit to `investigation.yaml` (locked system meta-playbook; the agent reaches this sub-flow by judgment). +- No per-provider variants; one provider-agnostic playbook. +- No per-axis node branching in the investigate phase (one lean node, by design). +- No new MCP tools or code paths; this is content over the existing cloud MCP surface. diff --git a/docs/superpowers/specs/2026-06-03-aws-owned-config-file-design.md b/docs/superpowers/specs/2026-06-03-aws-owned-config-file-design.md new file mode 100644 index 00000000..a31e606e --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-aws-owned-config-file-design.md @@ -0,0 +1,40 @@ +# Per-profile AWS managed config file + +## Problem + +triagent generates the AWS assume-role profiles by editing the operator's shared `~/.aws/config` in place, inside `# BEGIN/END triagent-cloud-` comment markers. The blast radius is the whole file: a single stray line (observed in the field) makes the aws CLI reject `~/.aws/config` entirely, so *every* profile fails, including the operator's own base SSO. Renaming a source alias also leaves an orphaned managed block behind. A fail-closed write guard now prevents triagent from landing unparseable content, but it does not address the root concern: triagent should not be mutating a file it does not own. + +## Design + +triagent writes the generated profiles into a **triagent-owned, per-profile** config file and points the cloud MCP at it with `AWS_CONFIG_FILE`. The operator's `~/.aws/config` becomes read-only input. + +### Location + +`${XDG_CACHE_HOME}/triagent-mcp/${PROFILE_NAME}/aws/config`, a new `Paths.CloudCacheDir` field resolved like the existing `GitCacheDir` (`${XDG_CACHE_HOME}/triagent-mcp/${PROFILE_NAME}/git`). Per-profile, so two deployment profiles never clobber each other; overridable in a profile's `paths:` block; `${PROFILE_NAME}`-required validation already guards it. + +### File content + +`` + a sentinel line + ``. The managed region holds the `# BEGIN/END triagent-cloud-` blocks. On each generation (per AWS source, flock-serialized): + +1. Read the operator config fresh and strip any `# BEGIN/END triagent-cloud-*` blocks from it (so a pre-migration operator config does not duplicate ours). This is the base. +2. Read the existing target's managed region (everything after the sentinel). +3. Splice this alias's block into the managed region (`replaceBlock`, as today). +4. Assemble `base + sentinel + managed region`, fail-closed validate, atomic write. + +Copying the whole operator config makes the target self-contained, so `source_profile` resolves whatever its type (SSO session, static creds, chained). `AWS_SHARED_CREDENTIALS_FILE` is left at its default, so static source creds still resolve from `~/.aws/credentials`; SSO works because the `[sso-session]` is in the copy and the token cache (`~/.aws/sso/cache`) is shared. + +### Threading + +- `writeManagedProfiles`/`aws.New` take explicit source and target paths via `Options` instead of reading `os.Getenv` ambiently (so the launcher-side probe, which runs in a process without these env vars, can pass them directly). +- `mcpconfig` sets `AWS_CONFIG_FILE=` (so the aws CLI the MCP runs reads it) and a new `TRIAGENT_CLOUD_AWS_SOURCE_CONFIG=` (the provider copies from it) on the cloud subprocess; the serve command reads them into `Options`. +- `providers.Options`/`Source` carry the two paths so `ProbeSource` threads them on the launcher side. + +### Freshness + +The copy is a snapshot taken at generation time (launcher start / probe). SSO *re-login* is picked up (shared token cache); operator config *edits* need a relaunch. This matches today's behavior. + +## Out of scope + +- No change to the "triagent stores no cloud credential" property: the aws CLI still performs the assume-role from `source_profile`; triagent never holds credentials. +- No change to assume-role session length (the 1h/`duration_seconds` knob is separate, tracked elsewhere). +- No pruning of stale alias blocks inside the managed region beyond what a wholesale rebuild covers (the file is triagent-owned and regenerable). diff --git a/frontend/app/(main)/docs/[section]/client.tsx b/frontend/app/(main)/docs/[section]/client.tsx index b9eb732c..2339a6f5 100644 --- a/frontend/app/(main)/docs/[section]/client.tsx +++ b/frontend/app/(main)/docs/[section]/client.tsx @@ -2,22 +2,9 @@ import { usePathname, useRouter } from "next/navigation"; import { DocsView } from "@/components/DocsView"; +import { SECTION_IDS, type SectionID } from "@/lib/docs-sections"; -// Section ids must match DocsView's SectionID union exactly — that is -// the source of truth for which markdown files exist under /public/docs/. -type SectionID = "overview" | "investigations" | "watches" | "mcp" | "playbooks" | "wiki" | "repos" | "connections" | "profiles"; - -const VALID_SECTIONS = new Set([ - "overview", - "investigations", - "watches", - "mcp", - "playbooks", - "wiki", - "repos", - "connections", - "profiles", -]); +const VALID_SECTIONS = new Set(SECTION_IDS); // Reads the section id from the URL pathname. Cannot use useParams() // here: the static export pre-renders only the "_" placeholder shell, diff --git a/frontend/components/ConnectionsPanel.test.tsx b/frontend/components/ConnectionsPanel.test.tsx new file mode 100644 index 00000000..393d93a0 --- /dev/null +++ b/frontend/components/ConnectionsPanel.test.tsx @@ -0,0 +1,151 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ConnectionsPanel } from "./ConnectionsPanel"; +import { api, type ConnectionStatus } from "@/lib/api"; +import { DialogProvider } from "@/lib/dialog"; + +// The cloud pills live in the manage-connections modal alongside the Slack and +// incident.io cards; open it before asserting on cloud content. +async function renderPanelAndOpenModal() { + render( + + + , + ); + await waitFor(() => expect(api.getConnections).toHaveBeenCalled()); + await userEvent.click( + screen.getByRole("button", { name: "manage connections" }), + ); +} + +const baseStatus: ConnectionStatus = { + slack: false, + incidentio: false, + slack_channel_prefix: "", +}; + +describe("ConnectionsPanel cloud pills", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders the principal + reach shape per provider", async () => { + vi.spyOn(api, "getConnections").mockResolvedValue({ + ...baseStatus, + cloud: [ + { + alias: "prod-gcp", + provider: "gcp", + assumed_identity: "triage-ro@prod.iam.gserviceaccount.com", + projects: ["prod-platform", "prod-data"], + valid: true, + }, + { + alias: "prod-aws", + provider: "aws", + accounts: ["111111111111", "222222222222"], + source_profile: "sso-admin", + valid: false, + hint: "run: aws sso login", + }, + ], + }); + + await renderPanelAndOpenModal(); + + expect(await screen.findByText("prod-gcp")).toBeInTheDocument(); + expect(screen.getByText("prod-aws")).toBeInTheDocument(); + // gcp: the impersonated service account over its allowlisted project count. + expect( + screen.getByText("triage-ro@prod.iam.gserviceaccount.com"), + ).toBeInTheDocument(); + const gcpReach = screen.getByText("2 projects"); + expect(gcpReach).toHaveAttribute("title", "prod-platform, prod-data"); + // aws: the SSO base profile over its account count, never a single identity. + expect(screen.getByText("base: sso-admin")).toBeInTheDocument(); + const awsReach = screen.getByText("2 accounts"); + expect(awsReach).toHaveAttribute("title", "111111111111, 222222222222"); + }); + + it("renders a one-entry reach as singular, and an empty gcp scope as all projects", async () => { + vi.spyOn(api, "getConnections").mockResolvedValue({ + ...baseStatus, + cloud: [ + { + alias: "single-aws", + provider: "aws", + accounts: ["123456789012"], + source_profile: "sso-admin", + valid: true, + }, + { + alias: "open-gcp", + provider: "gcp", + assumed_identity: "ro@p.iam.gserviceaccount.com", + valid: true, + }, + ], + }); + + await renderPanelAndOpenModal(); + + expect(await screen.findByText("1 account")).toBeInTheDocument(); + expect(screen.getByText("all projects")).toBeInTheDocument(); + }); + + it("shows the reauth hint only for an invalid source", async () => { + vi.spyOn(api, "getConnections").mockResolvedValue({ + ...baseStatus, + cloud: [ + { + alias: "prod-aws", + provider: "aws", + accounts: ["111111111111"], + source_profile: "sso-admin", + valid: false, + hint: "run: aws sso login", + }, + ], + }); + + await renderPanelAndOpenModal(); + + expect(await screen.findByText("run: aws sso login")).toBeInTheDocument(); + }); + + it("renders no edit affordance for cloud pills", async () => { + vi.spyOn(api, "getConnections").mockResolvedValue({ + ...baseStatus, + cloud: [ + { + alias: "prod-gcp", + provider: "gcp", + assumed_identity: "triage-ro@prod.iam.gserviceaccount.com", + valid: true, + }, + ], + }); + + await renderPanelAndOpenModal(); + + await screen.findByText("triage-ro@prod.iam.gserviceaccount.com"); + const pill = screen + .getByText("triage-ro@prod.iam.gserviceaccount.com") + .closest("[data-cloud-pill]"); + expect(pill).not.toBeNull(); + expect(pill!.querySelector("button")).toBeNull(); + expect(pill!.querySelector("input")).toBeNull(); + }); + + it("renders no cloud section when there are no cloud sources", async () => { + vi.spyOn(api, "getConnections").mockResolvedValue(baseStatus); + + await renderPanelAndOpenModal(); + + await waitFor(() => { + expect(api.getConnections).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("cloud-connections")).toBeNull(); + }); +}); diff --git a/frontend/components/ConnectionsPanel.tsx b/frontend/components/ConnectionsPanel.tsx index 548cf2d6..f069dbce 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/ConnectionsPanel.tsx @@ -1,9 +1,21 @@ "use client"; import { useEffect, useState } from "react"; -import { api, ApiError, type ConnectionStatus } from "@/lib/api"; +import { + api, + ApiError, + type CloudConnection, + type ConnectionStatus, +} from "@/lib/api"; import { useDialog } from "@/lib/dialog"; -import { IncidentIoIcon, SlackIcon } from "./Icons"; +import { + AwsIcon, + CheckIcon, + CloudIcon, + GcpIcon, + IncidentIoIcon, + SlackIcon, +} from "./Icons"; import { Spinner } from "./Spinner"; // ConnectionsPanel sits at the bottom of the sidenav next to @@ -189,11 +201,144 @@ function ManageConnectionsModal({ onChanged={onChanged} /> + + + + + ); +} + +// CloudConnectionsSection renders the profile-configured cloud connections as +// read-only pills: the assumed identity with a checkmark when the probe is +// valid, the reauth hint when not. Cloud is configured in the profile, never +// entered here — these pills carry no edit affordance. Omitted entirely when no +// cloud sources are configured. +function CloudConnectionsSection({ cloud }: { cloud: CloudConnection[] }) { + if (cloud.length === 0) return null; + return ( +
+
+ + cloud +
+

+ Read-only identities pinned in the profile’s{" "} + cloud: block, not edited + here. See the Cloud providers docs for setup. +

+
+ {cloud.map((c) => ( + + ))} +
+
+ ); +} + +// ProviderMark renders the cloud provider's brand icon, falling back to the +// generic cloud glyph for an unrecognised provider. The provider name stays in +// the title/aria-label so it is still announced and discoverable. +function ProviderMark({ provider }: { provider: string }) { + const title = provider.toUpperCase(); + const icon = + provider === "aws" ? ( + + ) : provider === "gcp" ? ( + + ) : ( + + ); + return ( + + {icon} + + ); +} + +function CloudPill({ conn }: { conn: CloudConnection }) { + return ( +
+
+
+ + + {conn.alias} + +
+ {conn.valid ? ( + + + valid + + ) : ( + + unavailable + + )}
+ + {!conn.valid && ( +
+ {conn.hint ?? reauthHint(conn.provider)} +
+ )}
); } +// CloudPillBody renders the same two-line shape for both providers — the +// principal over the reach it grants — with provider-specific content. GCP: the +// impersonated service account over its allowlisted project count. AWS: the SSO +// base profile over its account count. The reach line's full members are in its +// hover title. +function CloudPillBody({ conn }: { conn: CloudConnection }) { + const { principal, reach, reachTitle } = + conn.provider === "aws" + ? { + principal: conn.source_profile ? `base: ${conn.source_profile}` : "", + reach: countLabel(conn.accounts?.length ?? 0, "account"), + reachTitle: (conn.accounts ?? []).join(", "), + } + : { + principal: conn.assumed_identity ?? "", + // An empty projects allowlist means the identity reaches any project + // its IAM grants, not zero. + reach: + (conn.projects?.length ?? 0) === 0 + ? "all projects" + : countLabel(conn.projects!.length, "project"), + reachTitle: (conn.projects ?? []).join(", "), + }; + return ( + <> +
+ {principal} +
+
+ {reach} +
+ + ); +} + +function countLabel(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? "" : "s"}`; +} + +// reauthHint is the operator's own re-login command for a stale cloud identity, +// shown on an invalid pill when the probe did not supply a more specific hint. +function reauthHint(provider: string): string { + if (provider === "gcp") return "Re-authenticate with: gcloud auth login"; + if (provider === "aws") return "Re-authenticate with: aws sso login"; + return "Re-authenticate through your own cloud login"; +} + type ConnectionCardProps = { label: string; icon: React.ReactNode; diff --git a/frontend/components/DocsView.tsx b/frontend/components/DocsView.tsx index 490a9e61..be74ab7a 100644 --- a/frontend/components/DocsView.tsx +++ b/frontend/components/DocsView.tsx @@ -3,60 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; - -// Section ids match the markdown filenames under /public/docs/. The -// human label drives the left-rail rendering; the slug is the URL -// query value the page persists (so deep links into /?view=docs&docs=mcp -// land on the right page). -type SectionID = "overview" | "investigations" | "watches" | "mcp" | "playbooks" | "wiki" | "repos" | "connections" | "profiles"; - -const SECTIONS: { id: SectionID; label: string; subtitle: string }[] = [ - { - id: "overview", - label: "Overview", - subtitle: "What Triagent is and what you can do with it", - }, - { - id: "investigations", - label: "Investigate", - subtitle: "AI-driven cluster triage", - }, - { - id: "watches", - label: "Watches", - subtitle: "Persistent eyes-on a source", - }, - { - id: "mcp", - label: "MCP", - subtitle: "Tool servers the agent uses", - }, - { - id: "playbooks", - label: "Playbooks", - subtitle: "Structured procedural knowledge", - }, - { - id: "repos", - label: "Repos", - subtitle: "Linked GitHub projects + architecture summaries", - }, - { - id: "wiki", - label: "Wiki", - subtitle: "Persistent know-how", - }, - { - id: "connections", - label: "Connections", - subtitle: "Slack and incident.io integrations", - }, - { - id: "profiles", - label: "Profiles", - subtitle: "Forking the default to fit your platform", - }, -]; +import { SECTIONS, type SectionID } from "@/lib/docs-sections"; type Props = { // Section the operator picked from the docs sidebar. Driven by the diff --git a/frontend/components/Icons.tsx b/frontend/components/Icons.tsx index f4161336..f6a72b47 100644 --- a/frontend/components/Icons.tsx +++ b/frontend/components/Icons.tsx @@ -15,6 +15,7 @@ import { ChevronLeft, ChevronRight, ChevronUp, + Cloud, CloudUpload, Copy, Download, @@ -32,6 +33,7 @@ type Props = { className?: string }; export const ArrowLeftIcon = ArrowLeft; export const ArrowRightIcon = ArrowRight; export const CheckIcon = Check; +export const CloudIcon = Cloud; export const ChevronDownIcon = ChevronDown; export const ChevronLeftIcon = ChevronLeft; export const ChevronRightIcon = ChevronRight; @@ -88,6 +90,62 @@ export function IncidentIoIcon({ className }: Props) { ); } +// AwsIcon is a brand-suggestive AWS mark: the orange "smile" arrow that +// anchors the AWS wordmark. AWS doesn't redistribute its full logo as an +// inline asset, so this renders the recognisable smile-and-arrow alone, in +// AWS orange. Colours are baked in; callers don't override fill. +export function AwsIcon({ className }: Props) { + return ( + + + + + ); +} + +// GcpIcon is a brand-suggestive Google Cloud mark: a cloud silhouette filled +// with Google's four-colour sweep (blue, red, yellow, green). Approximates the +// Google Cloud identity at sidebar size without redistributing the official +// asset. Colours are baked in; callers don't override fill. +export function GcpIcon({ className }: Props) { + return ( + + + + + + + + + + + + ); +} + // ChatBubbleIcon is the editor chat-toggle glyph used by both the // playbook and wiki editors. Stroke-only line so it renders cleanly // at small sizes alongside the action-row labels. diff --git a/frontend/components/MCPStatusBar.tsx b/frontend/components/MCPStatusBar.tsx index 83d8b907..104624de 100644 --- a/frontend/components/MCPStatusBar.tsx +++ b/frontend/components/MCPStatusBar.tsx @@ -98,7 +98,7 @@ export function MCPStatusBar({ investigation }: Props) { )} -
+
{mcps.map((m) => { const stats = health[m.alias]; const probe = probes[m.alias]; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index d01ce7a8..0b438f2f 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -89,6 +89,38 @@ export type ConnectionStatus = { slack: boolean; incidentio: boolean; slack_channel_prefix: string; + // cloud is the read-only list of profile-configured cloud connections, + // each probed at request time. Configured in the profile, never entered + // in the panel. + cloud?: CloudConnection[]; +}; + +// CloudConnection is one read-only cloud source: the alias keying its +// triagent-cloud- MCP and the request-time identity-probe result. valid +// drives the checkmark; hint is the reauth advice shown when the probe failed. +// +// Each pill renders the same principal + reach shape, with provider-specific +// content. gcp carries assumed_identity (the impersonated service account) and +// projects (its scope allowlist). aws carries source_profile (the operator's SSO +// base) and accounts (the account ids it spans). +export type CloudConnection = { + alias: string; + provider: string; + assumed_identity?: string; + projects?: string[]; + source_profile?: string; + accounts?: string[]; + valid: boolean; + hint?: string; +}; + +// CloudMCP is one cloud-context MCP wired into a session: its wire alias +// (triagent-cloud-, matching mcp____ in tool names) +// and the provider, so the status bar can brand and colour the chip. Derived +// server-side from the profile's cloud sources, identical across sessions. +export type CloudMCP = { + alias: string; + provider: string; }; export type SlackChannel = { @@ -447,6 +479,9 @@ export type Investigation = { slackMCPEnabled?: boolean; incidentioMCPEnabled?: boolean; linkedRepos?: LinkedRepo[]; + // cloudMcps are the cloud-context MCP servers wired into this session, + // derived from the profile's cloud sources. Absent when none are configured. + cloudMcps?: CloudMCP[]; createdAt: string; started: boolean; streaming: boolean; diff --git a/frontend/lib/docs-sections.test.ts b/frontend/lib/docs-sections.test.ts new file mode 100644 index 00000000..324c1c61 --- /dev/null +++ b/frontend/lib/docs-sections.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { SECTIONS, SECTION_IDS } from "@/lib/docs-sections"; + +describe("docs sections registry", () => { + it("derives SECTION_IDS from SECTIONS so the route and rail cannot drift", () => { + expect(SECTION_IDS).toEqual(SECTIONS.map((s) => s.id)); + }); + + it("includes cloud-providers as a routable section", () => { + // Regression: the route's validation list had drifted from the rail and + // dropped this id, so clicking it fell back to overview. + expect(SECTION_IDS).toContain("cloud-providers"); + }); +}); diff --git a/frontend/lib/docs-sections.ts b/frontend/lib/docs-sections.ts new file mode 100644 index 00000000..bdd54586 --- /dev/null +++ b/frontend/lib/docs-sections.ts @@ -0,0 +1,75 @@ +// Single source of truth for the docs sections. Both the docs view (left-rail +// rendering) and the /docs/[section] route (URL validation) import from here, so +// adding a section in one place can never drift from the other. Each id matches +// a markdown filename under /public/docs/. + +export type SectionID = + | "overview" + | "investigations" + | "watches" + | "mcp" + | "playbooks" + | "repos" + | "wiki" + | "connections" + | "cloud-providers" + | "profiles"; + +export type Section = { id: SectionID; label: string; subtitle: string }; + +export const SECTIONS: Section[] = [ + { + id: "overview", + label: "Overview", + subtitle: "What Triagent is and what you can do with it", + }, + { + id: "investigations", + label: "Investigate", + subtitle: "AI-driven cluster triage", + }, + { + id: "watches", + label: "Watches", + subtitle: "Persistent eyes-on a source", + }, + { + id: "mcp", + label: "MCP", + subtitle: "Tool servers the agent uses", + }, + { + id: "playbooks", + label: "Playbooks", + subtitle: "Structured procedural knowledge", + }, + { + id: "repos", + label: "Repos", + subtitle: "Linked GitHub projects + architecture summaries", + }, + { + id: "wiki", + label: "Wiki", + subtitle: "Persistent know-how", + }, + { + id: "connections", + label: "Connections", + subtitle: "Slack and incident.io integrations", + }, + { + id: "cloud-providers", + label: "Cloud providers", + subtitle: "Read-only GCP and AWS investigation context", + }, + { + id: "profiles", + label: "Profiles", + subtitle: "Forking the default to fit your platform", + }, +]; + +// SECTION_IDS is the set of valid section slugs, derived from SECTIONS so the +// two never diverge. The route uses it to validate the URL section param. +export const SECTION_IDS: SectionID[] = SECTIONS.map((s) => s.id); diff --git a/frontend/lib/mcps.test.ts b/frontend/lib/mcps.test.ts new file mode 100644 index 00000000..7a4e496c --- /dev/null +++ b/frontend/lib/mcps.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import type { Investigation } from "@/lib/api"; +import { activeMCPs, chipClasses } from "@/lib/mcps"; + +function makeInv(overrides: Partial = {}): Investigation { + return { + id: "i1", + namespace: "", + mcpConfigPath: "", + sessionDir: "", + promEnabled: false, + createdAt: new Date().toISOString(), + started: false, + streaming: false, + archived: false, + syncState: { status: "local-only" }, + ...overrides, + } as Investigation; +} + +describe("activeMCPs cloud sources", () => { + it("emits a cloud chip per wired cloud MCP, keyed by its wire alias", () => { + const mcps = activeMCPs( + makeInv({ + cloudMcps: [ + { alias: "triagent-cloud-prod-gcp", provider: "gcp" }, + { alias: "triagent-cloud-prod-aws", provider: "aws" }, + ], + }), + ); + const cloud = mcps.filter((m) => m.category === "cloud"); + expect(cloud.map((m) => m.alias)).toEqual([ + "triagent-cloud-prod-gcp", + "triagent-cloud-prod-aws", + ]); + expect(cloud[0].description).toContain("GCP"); + expect(cloud[1].description).toContain("AWS"); + }); + + it("emits no cloud chips when no cloud sources are wired", () => { + const mcps = activeMCPs(makeInv()); + expect(mcps.some((m) => m.category === "cloud")).toBe(false); + }); + + it("gives the cloud category its own chip styling", () => { + expect(chipClasses("cloud")).not.toBe(""); + expect(chipClasses("cloud")).not.toBe(chipClasses("docs")); + }); +}); diff --git a/frontend/lib/mcps.ts b/frontend/lib/mcps.ts index 3b55eb67..2d4f0de2 100644 --- a/frontend/lib/mcps.ts +++ b/frontend/lib/mcps.ts @@ -4,7 +4,7 @@ import type { Investigation, MCPCallStats, MCPProbeResult, ToolEntry } from "./api"; -export type MCPCategory = "core" | "metrics" | "docs" | "git" | "wiki" | "slack" | "incidentio"; +export type MCPCategory = "core" | "metrics" | "docs" | "git" | "wiki" | "slack" | "incidentio" | "cloud"; export type ActiveMCP = { alias: string; // wire alias (matches mcp____ in tool names) @@ -92,6 +92,14 @@ export function activeMCPs(inv: Investigation): ActiveMCP[] { }); } + for (const c of inv.cloudMcps ?? []) { + out.push({ + alias: c.alias, + category: "cloud", + description: `Read-only ${c.provider.toUpperCase()} cloud context — list_inventory, run_cli (allowlisted reads), set_active_target, session_status.`, + }); + } + return out; } @@ -114,6 +122,8 @@ export function chipClasses(c: MCPCategory): string { return "border-pink-500/70 bg-pink-500/20 text-pink-300"; case "incidentio": return "border-rose-900/60 bg-rose-950/40 text-rose-300"; + case "cloud": + return "border-orange-900/60 bg-orange-950/40 text-orange-300"; } } diff --git a/go.mod b/go.mod index 42ce17c4..f023b3cf 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + golang.org/x/sys v0.43.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.3 k8s.io/apimachinery v0.35.3 @@ -99,7 +100,6 @@ require ( golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index 6d78873f..6277493d 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -12,6 +12,8 @@ import ( "github.com/sourcehawk/triagent/internal/profile" "github.com/sourcehawk/triagent/internal/promforward" "github.com/sourcehawk/triagent/internal/repos" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" ) var envRefRe = regexp.MustCompile(`^\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}$`) @@ -47,6 +49,10 @@ const ( // MCPAliasGitPrefix is prepended to a repo's effective alias to form // the per-repo MCP server alias (e.g. "triagent-git-zeebe"). MCPAliasGitPrefix = "triagent-git-" + // MCPAliasCloudPrefix is prepended to a cloud source's alias to form the + // per-source MCP server alias (e.g. "triagent-cloud-prod-gcp"), mirroring + // the per-repo git prefix. + MCPAliasCloudPrefix = "triagent-cloud-" ) // Env-var names the launcher injects into each triagent-mcp subcommand. These @@ -101,7 +107,11 @@ type mcpConfigInputs struct { // and passed to triagent-mcp as --crds-file. TRIAGENT_MCP_CRDS_FILE env wins. Profile *profile.Profile LinkedRepos []repos.LinkedRepo // each becomes a `triagent-git-` server entry + // CloudSources are the profile's read-only cloud connections; each becomes a + // `triagent-cloud-` server entry pinned to that source's identity. + CloudSources []profile.CloudSource GitCacheDir string // optional override for git repo cache root + CloudCacheDir string // per-profile dir for the triagent-owned AWS config (/config) UserPlaybooksDir string // optional override-or-extend dir for strategies playbooks PluginPlaybooksDir string // launcher-managed clone of the upstream playbooks repo (overridable) SystemPlaybooksDir string // launcher-bundled meta-playbooks (locked, non-overridable) @@ -169,6 +179,98 @@ func kubeEnv(in mcpConfigInputs) map[string]string { return out } +// cloudSourceEnv builds the subprocess env for one triagent-cloud- +// server: the provider selector, the optional allowlist-override path, the +// JSON-encoded scope the cloud package decodes, the pinned identity the probe +// validates against, and the per-provider credential env the CLI authenticates +// with. +// +// The pinned identity is uniform: TRIAGENT_CLOUD_EXPECTED_IDENTITY carries it +// for both clouds, and the probe validates the resolved identity against it. The +// credential env differs by mechanism: GCP impersonates the assumed identity +// directly (CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT). AWS carries its accounts +// and source_profile (TRIAGENT_CLOUD_AWS_ACCOUNTS, _SOURCE_PROFILE): the +// subprocess generates an assume-role profile per account and pins AWS_PROFILE +// per run_cli from the active target, so no static profile selector belongs in +// this env. The env-name constants come from the provider packages, never raw +// literals. +func cloudSourceEnv(src profile.CloudSource, cloudCacheDir string) (map[string]string, error) { + env := map[string]string{ + cloud.EnvProvider: src.Provider, + } + // The expected identity is provider-specific: gcp's impersonated service + // account, or aws's default (first) account's role_arn — the server validates + // each active aws account against its own role, and falls back to this default + // only before a target is chosen. + if src.Provider == "aws" { + if len(src.Accounts) > 0 { + env[cloud.EnvExpectedIdentity] = src.Accounts[0].RoleARN + } + } else { + env[cloud.EnvExpectedIdentity] = src.AssumedIdentity + } + if src.CommandAllowlistPath != "" { + env[cloud.EnvAllowlistPath] = src.CommandAllowlistPath + } + scopeRaw, err := json.Marshal(src.Scope) + if err != nil { + return nil, fmt.Errorf("cloud source %q: encode scope: %w", src.Alias, err) + } + env[cloud.EnvScope] = string(scopeRaw) + + switch src.Provider { + case "gcp": + env[gcp.EnvImpersonate] = src.AssumedIdentity + if len(src.Projects) > 0 { + projectsRaw, err := json.Marshal(src.Projects) + if err != nil { + return nil, fmt.Errorf("cloud source %q: encode projects: %w", src.Alias, err) + } + env[cloud.EnvGCPProjects] = string(projectsRaw) + } + case "aws": + accountsRaw, err := json.Marshal(src.Accounts) + if err != nil { + return nil, fmt.Errorf("cloud source %q: encode accounts: %w", src.Alias, err) + } + env[cloud.EnvAWSAccounts] = string(accountsRaw) + env[cloud.EnvAWSSourceProfile] = src.SourceProfile + env[cloud.EnvAWSAlias] = src.Alias + // Point the subprocess at the triagent-owned config (the aws CLI reads + // it, the provider generates into it) and name the operator config to + // copy from, so ~/.aws/config is never written. + if target := awsManagedConfigPath(cloudCacheDir); target != "" { + env[cloud.EnvAWSConfigFile] = target + env[cloud.EnvAWSSourceConfig] = operatorAWSConfigPath() + } + } + return env, nil +} + +// awsManagedConfigPath is the triagent-owned AWS config file for a profile: +// /config. Empty when no cache dir is configured (the provider +// then declines to generate rather than writing a surprising path). +func awsManagedConfigPath(cloudCacheDir string) string { + if cloudCacheDir == "" { + return "" + } + return filepath.Join(cloudCacheDir, "config") +} + +// operatorAWSConfigPath is the operator's own AWS config the managed file copies +// from: $AWS_CONFIG_FILE when the operator set one in the launcher's env, else +// $HOME/.aws/config. +func operatorAWSConfigPath() string { + if v := os.Getenv(cloud.EnvAWSConfigFile); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".aws", "config") +} + // resolveKindsFile returns the --crds-file path to pass to triagent-mcp's k8s // server, or "" when no override is in effect. Precedence: // 1. TRIAGENT_MCP_CRDS_FILE env (operator-direct override). @@ -314,6 +416,21 @@ func writeMCPConfig(in mcpConfigInputs) (string, error) { } } + for _, src := range in.CloudSources { + alias := MCPAliasCloudPrefix + src.Alias + cloudEnv, err := cloudSourceEnv(src, in.CloudCacheDir) + if err != nil { + return "", err + } + mergeEnv(cloudEnv, telemetryEnv(in, alias)) + mergeEnv(cloudEnv, kubeEnv(in)) + servers[alias] = map[string]any{ + "command": in.MCPBin, + "args": []string{"serve", "--kind=cloud", "--provider=" + src.Provider}, + "env": cloudEnv, + } + } + if in.SlackToken != "" { slackEnv := map[string]string{ EnvSlackToken: in.SlackToken, diff --git a/internal/preflight/mcpconfig_test.go b/internal/preflight/mcpconfig_test.go index a33b1e21..52e63cbf 100644 --- a/internal/preflight/mcpconfig_test.go +++ b/internal/preflight/mcpconfig_test.go @@ -9,6 +9,9 @@ import ( "github.com/sourcehawk/triagent/internal/profile" "github.com/sourcehawk/triagent/internal/repos" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -382,3 +385,138 @@ func TestWriteMCPConfig_KubeconfigInjectedIntoEveryServer(t *testing.T) { assert.Equal(t, "/tmp/kubeconfig", srv.Env["KUBECONFIG"], "server %q env KUBECONFIG", alias) } } + +func TestWriteMCPConfig_NoCloudSources_OmitsCloudServer(t *testing.T) { + t.Parallel() + in := baseInputs(t) + path, err := writeMCPConfig(in) + require.NoError(t, err) + for alias := range readMCPConfig(t, path) { + assert.NotContains(t, alias, MCPAliasCloudPrefix, + "no cloud sources means no triagent-cloud- server") + } +} + +func TestWriteMCPConfig_GCPCloudSource_RegistersServerWithImpersonationEnv(t *testing.T) { + t.Parallel() + in := baseInputs(t) + in.CloudSources = []profile.CloudSource{{ + Alias: "prod-gcp", + Provider: "gcp", + AssumedIdentity: "triage-ro@prod.iam.gserviceaccount.com", + Projects: []profile.CloudProject{{ID: "prod-a", Tags: []string{"prod"}}}, + CommandAllowlistPath: "/etc/triagent/gcp-allow.json", + }} + path, err := writeMCPConfig(in) + require.NoError(t, err) + servers := readMCPConfig(t, path) + + alias := MCPAliasCloudPrefix + "prod-gcp" + srv, ok := servers[alias] + require.True(t, ok, "expected %s server", alias) + + args, _ := srv["args"].([]any) + assert.Equal(t, []any{"serve", "--kind=cloud", "--provider=gcp"}, args) + + env, _ := srv["env"].(map[string]any) + require.NotNil(t, env) + assert.Equal(t, "gcp", env[cloud.EnvProvider]) + assert.Equal(t, "/etc/triagent/gcp-allow.json", env[cloud.EnvAllowlistPath]) + // The pinned identity is uniform across providers. + assert.Equal(t, "triage-ro@prod.iam.gserviceaccount.com", env[cloud.EnvExpectedIdentity]) + // gcp impersonates the assumed identity directly as its credential env. + assert.Equal(t, "triage-ro@prod.iam.gserviceaccount.com", env[gcp.EnvImpersonate]) + // AWS-specific env must not leak onto a gcp source. + assert.NotContains(t, env, aws.EnvProfile) + + // The configured projects (with tags) are JSON-encoded into the gcp env. + rawProjects, _ := env[cloud.EnvGCPProjects].(string) + require.NotEmpty(t, rawProjects, "gcp projects must be JSON-encoded into the env") + var projects []profile.CloudProject + require.NoError(t, json.Unmarshal([]byte(rawProjects), &projects)) + require.Len(t, projects, 1) + assert.Equal(t, "prod-a", projects[0].ID) + assert.Equal(t, []string{"prod"}, projects[0].Tags) +} + +func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithAccountsAndExpectedRole(t *testing.T) { + t.Parallel() + in := baseInputs(t) + in.CloudSources = []profile.CloudSource{{ + Alias: "prod-aws", + Provider: "aws", + SourceProfile: "sso-admin", + Accounts: []profile.CloudAccount{ + {AccountID: "123456789012", RoleARN: "arn:aws:iam::123456789012:role/triage-ro"}, + }, + Scope: cloud.ScopeAllowlist{Regions: []string{"us-east-1"}}, + }} + path, err := writeMCPConfig(in) + require.NoError(t, err) + servers := readMCPConfig(t, path) + + alias := MCPAliasCloudPrefix + "prod-aws" + srv, ok := servers[alias] + require.True(t, ok, "expected %s server", alias) + + args, _ := srv["args"].([]any) + assert.Equal(t, []any{"serve", "--kind=cloud", "--provider=aws"}, args) + + env, _ := srv["env"].(map[string]any) + require.NotNil(t, env) + assert.Equal(t, "aws", env[cloud.EnvProvider]) + // aws's expected identity is the default (first) account's role_arn, not a + // source-level assumed identity. + assert.Equal(t, "arn:aws:iam::123456789012:role/triage-ro", env[cloud.EnvExpectedIdentity]) + // aws carries its accounts and source_profile; AWS_PROFILE is pinned per-exec + // from the active target, never as a static selector here. + assert.Equal(t, "sso-admin", env[cloud.EnvAWSSourceProfile]) + assert.NotEmpty(t, env[cloud.EnvAWSAccounts]) + assert.NotContains(t, env, aws.EnvProfile) + // gcp impersonation env must not leak onto an aws source. + assert.NotContains(t, env, gcp.EnvImpersonate) +} + +func TestCloudSourceEnv_AWSAccounts_EmitsAccountsAndSourceProfile(t *testing.T) { + t.Parallel() + env, err := cloudSourceEnv(profile.CloudSource{ + Alias: "prod-aws", + Provider: "aws", + SourceProfile: "sso-admin", + Accounts: []profile.CloudAccount{ + {AccountID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/triage-ro"}, + {AccountID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/triage-ro"}, + }, + }, "/cache/triagent-mcp/p/aws") + require.NoError(t, err) + + assert.Equal(t, "sso-admin", env[cloud.EnvAWSSourceProfile]) + // AWS sources point the subprocess at the triagent-owned config (not + // ~/.aws/config) and name the operator config to copy from. + assert.Equal(t, "/cache/triagent-mcp/p/aws/config", env[cloud.EnvAWSConfigFile]) + assert.NotEmpty(t, env[cloud.EnvAWSSourceConfig], "operator source config must be named") + require.NotEmpty(t, env[cloud.EnvAWSAccounts], "accounts must be emitted as JSON") + + var decoded []profile.CloudAccount + require.NoError(t, json.Unmarshal([]byte(env[cloud.EnvAWSAccounts]), &decoded)) + require.Len(t, decoded, 2) + assert.Equal(t, "111111111111", decoded[0].AccountID) + assert.Equal(t, "arn:aws:iam::222222222222:role/triage-ro", decoded[1].RoleARN) + + // The multi-account form pins AWS_PROFILE per-exec from the active target, so + // the subprocess credential env carries no static profile selector. + assert.NotContains(t, env, aws.EnvProfile) +} + +func TestCloudSourceEnv_GCP_CarriesNoAWSAccountsEnv(t *testing.T) { + t.Parallel() + env, err := cloudSourceEnv(profile.CloudSource{ + Alias: "prod-gcp", + Provider: "gcp", + AssumedIdentity: "ro@proj.iam.gserviceaccount.com", + }, "/cache/triagent-mcp/p/aws") + require.NoError(t, err) + assert.NotContains(t, env, cloud.EnvAWSAccounts) + assert.NotContains(t, env, cloud.EnvAWSSourceProfile) + assert.NotContains(t, env, cloud.EnvAWSConfigFile, "gcp sources never set AWS_CONFIG_FILE") +} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 9a438155..95157839 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -18,6 +18,9 @@ import ( "github.com/sourcehawk/triagent/internal/promforward" "github.com/sourcehawk/triagent/internal/repos" "github.com/sourcehawk/triagent/pkg/auth" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" ) // Options describes a single preflight invocation. @@ -41,6 +44,11 @@ type Options struct { // default ($XDG_CACHE_HOME/triagent-mcp/git or ~/.cache/triagent-mcp/git). GitCacheDir string + // CloudCacheDir is the per-profile dir holding the triagent-owned AWS config + // (/config). The AWS cloud MCP reads it via AWS_CONFIG_FILE and the + // provider generates into it, so ~/.aws/config is never written. + CloudCacheDir string + // UserPlaybooksDir is the directory holding operator-customised // strategy playbooks; the launcher's editor writes there, the // strategies MCP layers them on top of the system set. Empty means @@ -109,6 +117,21 @@ type Options struct { // entry even if PromTarget is non-nil. Set when the operator opts out // in the preflight form. PromDisabled bool + + // CloudProbe runs the read-only identity probe for one cloud source. Nil + // uses the default prober (providers.ProbeSource), which constructs the + // source's provider and shells its CLI; tests inject a stub. The probe + // degrades, never blocks — a failed probe marks the source unavailable but + // the session still starts. + CloudProbe func(context.Context, profile.CloudSource) cloud.IdentityStatus +} + +// CloudSourceStatus is one cloud source's preflight outcome: its alias and the +// identity-probe result. A source with Valid:false started the session degraded +// — visibly unavailable, with Hint pointing at the fix. +type CloudSourceStatus struct { + Alias string + cloud.IdentityStatus } // Result holds the artifacts a successful preflight produces. @@ -116,6 +139,9 @@ type Result struct { MCPConfigPath string DocsPrefix string // e.g. "mcp__example-docs__"; empty when not registered KubeconfigPath string // resolved + frozen path; mirrored back to caller for persistence + // CloudSources is the per-source identity-probe outcome for each profile + // cloud source. A failed probe degrades that source, never the session. + CloudSources []CloudSourceStatus } // Run performs the full preflight sequence. On any failure, in-flight @@ -151,6 +177,17 @@ func Run(opts Options) (*Result, error) { } } + // Probe the cloud sources before writing the MCP config so a failed probe + // disables the source rather than merely reporting it: only sources whose + // probe is Valid are wired as MCP servers. The full set (valid and degraded) + // stays in Result.CloudSources so the status surface still shows the + // degraded ones with their hint. The probe degrades, never blocks. + cloudProbe := opts.CloudProbe + if cloudProbe == nil { + cloudProbe = NewCloudProbe(opts.CloudCacheDir) + } + cloudStatuses := probeCloudSources(opts.Ctx, cloudSources(opts.Profile), cloudProbe) + mcpPath, err := writeMCPConfig(mcpConfigInputs{ Dir: opts.SessionDir, MCPBin: opts.MCPBinaryPath, @@ -158,7 +195,9 @@ func Run(opts Options) (*Result, error) { KubeconfigPath: kubeconfigPath, Profile: opts.Profile, LinkedRepos: opts.LinkedRepos, + CloudSources: validCloudSources(cloudSources(opts.Profile), cloudStatuses), GitCacheDir: opts.GitCacheDir, + CloudCacheDir: opts.CloudCacheDir, UserPlaybooksDir: opts.UserPlaybooksDir, PluginPlaybooksDir: opts.PluginPlaybooksDir, SystemPlaybooksDir: opts.SystemPlaybooksDir, @@ -185,9 +224,98 @@ func Run(opts Options) (*Result, error) { MCPConfigPath: mcpPath, DocsPrefix: docsPrefix, KubeconfigPath: kubeconfigPath, + CloudSources: cloudStatuses, }, nil } +// validCloudSources returns the subset of sources whose probe came back Valid, +// keyed by alias. A degraded source is dropped here so it is never wired as an +// MCP server, while it remains in Result.CloudSources for the status surface. +func validCloudSources(sources []profile.CloudSource, statuses []CloudSourceStatus) []profile.CloudSource { + valid := make(map[string]bool, len(statuses)) + for _, s := range statuses { + valid[s.Alias] = s.Valid + } + out := make([]profile.CloudSource, 0, len(sources)) + for _, src := range sources { + if valid[src.Alias] { + out = append(out, src) + } + } + return out +} + +// probeCloudSources runs the identity probe for each cloud source and returns +// its per-source status. It degrades, never blocks: a failed probe marks the +// source unavailable with a hint, and the session proceeds regardless. probe +// defaults to the real prober (providers.ProbeSource) when nil. +func probeCloudSources(ctx context.Context, sources []profile.CloudSource, probe func(context.Context, profile.CloudSource) cloud.IdentityStatus) []CloudSourceStatus { + if len(sources) == 0 { + return nil + } + if probe == nil { + probe = DefaultCloudProbe + } + out := make([]CloudSourceStatus, 0, len(sources)) + for _, src := range sources { + out = append(out, CloudSourceStatus{ + Alias: src.Alias, + IdentityStatus: probe(ctx, src), + }) + } + return out +} + +// NewCloudProbe returns the real prober bound to a profile's CloudCacheDir: it +// maps a profile cloud source to the providers package's neutral Source (with +// the AWS managed-config target/source paths derived from cloudCacheDir) and +// runs ProbeSource, which constructs the provider and shells its whoami CLI. A +// construction error degrades to an invalid status, never a session-fatal error. +// The session preflight gate and the connections panel both probe through it, so +// the two surfaces resolve the same identity and can never disagree. +func NewCloudProbe(cloudCacheDir string) func(context.Context, profile.CloudSource) cloud.IdentityStatus { + return func(ctx context.Context, src profile.CloudSource) cloud.IdentityStatus { + return providers.ProbeSource(ctx, cloudProbeSource(src, cloudCacheDir)) + } +} + +// DefaultCloudProbe is NewCloudProbe with no cache dir: usable for gcp and for +// tests, but an aws source with accounts needs a cache dir to generate its +// managed config, so production callers build the probe with NewCloudProbe. +func DefaultCloudProbe(ctx context.Context, src profile.CloudSource) cloud.IdentityStatus { + return NewCloudProbe("")(ctx, src) +} + +// cloudProbeSource maps a profile cloud source to the providers package's +// neutral probe Source, threading the aws multi-account fields (alias, source +// profile, accounts) so a multi-account source probes its default account's +// generated profile, plus the managed-config target/source paths so the probe's +// aws CLI reads the triagent-owned config rather than ~/.aws/config. +func cloudProbeSource(src profile.CloudSource, cloudCacheDir string) providers.Source { + accounts := make([]aws.Account, 0, len(src.Accounts)) + for _, a := range src.Accounts { + accounts = append(accounts, aws.Account{ID: a.AccountID, RoleARN: a.RoleARN}) + } + return providers.Source{ + Provider: src.Provider, + AssumedIdentity: src.AssumedIdentity, + Alias: src.Alias, + SourceProfile: src.SourceProfile, + Accounts: accounts, + ConfigTarget: awsManagedConfigPath(cloudCacheDir), + ConfigSource: operatorAWSConfigPath(), + } +} + +// cloudSources returns the profile's read-only cloud connections, or nil when +// no profile is loaded. Each becomes a triagent-cloud- MCP server. +func cloudSources(prof *profile.Profile) []profile.CloudSource { + if prof == nil { + return nil + } + return prof.Cloud +} + // freezeKubeconfig writes a session-private copy of the operator's // kubeconfig into sessionDir and returns its path. Every MCP we spawn for // this session receives KUBECONFIG pointing at the copy, so agent-side diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go index 74d3fb7c..f1e5adf9 100644 --- a/internal/preflight/preflight_test.go +++ b/internal/preflight/preflight_test.go @@ -2,6 +2,7 @@ package preflight import ( "context" + "encoding/json" "errors" "os" "path/filepath" @@ -12,7 +13,11 @@ import ( "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "github.com/sourcehawk/triagent/internal/profile" "github.com/sourcehawk/triagent/pkg/auth" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" ) // fakeProvider lets the preflight gate be tested without a real tsh session. @@ -224,3 +229,115 @@ func TestRun_EmptyNamespaceAuthenticatedWritesConfig(t *testing.T) { _, statErr := os.Stat(res.MCPConfigPath) assert.NoError(t, statErr, "MCPConfigPath should reference an existing file") } + +// A failed cloud probe degrades the source but never fails the session: the +// session still starts, and the source is marked unavailable in the Result +// with the probe's hint attached. This is the cloud-source-scoped soft-degrade +// path; the k8s block-on-failure behaviour is unchanged. +func TestRun_CloudProbeFailureDegradesNotBlocks(t *testing.T) { + t.Parallel() + prof := &profile.Profile{ + Cloud: []profile.CloudSource{ + {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com"}, + {Alias: "prod-aws", Provider: "aws", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, + }, + } + res, err := Run(Options{ + Provider: fakeProvider{authenticated: true}, + SessionDir: t.TempDir(), + MCPBinaryPath: "/tmp/triagent-mcp", + Profile: prof, + CloudProbe: func(_ context.Context, src profile.CloudSource) cloud.IdentityStatus { + if src.Alias == "prod-gcp" { + return cloud.IdentityStatus{Provider: "gcp", AssumedIdentity: src.AssumedIdentity, Valid: true} + } + return cloud.IdentityStatus{Provider: "aws", AssumedIdentity: src.AssumedIdentity, Valid: false, Hint: "run: aws sso login"} + }, + }) + require.NoError(t, err, "a failed cloud probe must not fail the session") + require.Len(t, res.CloudSources, 2) + + byAlias := map[string]CloudSourceStatus{} + for _, s := range res.CloudSources { + byAlias[s.Alias] = s + } + assert.True(t, byAlias["prod-gcp"].Valid, "valid source must be available") + assert.False(t, byAlias["prod-aws"].Valid, "failed probe must mark the source unavailable") + assert.Equal(t, "run: aws sso login", byAlias["prod-aws"].Hint) + + // The degraded source must NOT be wired as an MCP server, while the valid + // one is: a failed probe disables the source, it doesn't merely report it. + servers := readMCPServers(t, res.MCPConfigPath) + assert.Contains(t, servers, MCPAliasCloudPrefix+"prod-gcp", + "valid source must be registered as an MCP server") + assert.NotContains(t, servers, MCPAliasCloudPrefix+"prod-aws", + "degraded source must be absent from the written MCP config") +} + +// readMCPServers loads the written mcp.json and returns its mcpServers map. +func readMCPServers(t *testing.T, path string) map[string]any { + t.Helper() + body, err := os.ReadFile(path) + require.NoError(t, err) + var cfg struct { + MCPServers map[string]any `json:"mcpServers"` + } + require.NoError(t, json.Unmarshal(body, &cfg)) + return cfg.MCPServers +} + +// A provider construction error (e.g. the CLI binary missing) degrades the +// source exactly like a failed probe — it is never a session-fatal error. +func TestRun_CloudProviderConstructionErrorDegrades(t *testing.T) { + t.Parallel() + prof := &profile.Profile{ + Cloud: []profile.CloudSource{ + {Alias: "no-cli", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com"}, + }, + } + // The default real prober runs through providers.New, which errors when + // gcloud is absent; assert the session still starts and the source is + // marked unavailable with a hint, whatever the host environment. + res, err := Run(Options{ + Provider: fakeProvider{authenticated: true}, + SessionDir: t.TempDir(), + MCPBinaryPath: "/tmp/triagent-mcp", + Profile: prof, + }) + require.NoError(t, err, "a provider construction error must not fail the session") + require.Len(t, res.CloudSources, 1) + assert.Equal(t, "no-cli", res.CloudSources[0].Alias) +} + +// TestCloudProbeSource_ThreadsAWSMultiAccountFields pins that the source mapping +// shared by the preflight gate and the connections panel carries the aws +// multi-account fields. Dropping them probes a multi-account source with an +// empty AWS_PROFILE, which would show a valid source as unavailable. +func TestCloudProbeSource_ThreadsAWSMultiAccountFields(t *testing.T) { + // Not parallel: sets AWS_CONFIG_FILE so the copied operator-config path is + // deterministic. + t.Setenv("AWS_CONFIG_FILE", "/op/aws/config") + src := profile.CloudSource{ + Alias: "prod-aws", + Provider: "aws", + SourceProfile: "sso-base", + Accounts: []profile.CloudAccount{ + {AccountID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/triage-ro"}, + {AccountID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/triage-ro"}, + }, + } + + got := cloudProbeSource(src, "/cache/triagent-mcp/p/aws") + + assert.Equal(t, providers.Source{ + Provider: "aws", + Alias: "prod-aws", + SourceProfile: "sso-base", + Accounts: []aws.Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/triage-ro"}, + {ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/triage-ro"}, + }, + ConfigTarget: "/cache/triagent-mcp/p/aws/config", + ConfigSource: "/op/aws/config", + }, got) +} diff --git a/internal/profile/cloud_base_test.go b/internal/profile/cloud_base_test.go new file mode 100644 index 00000000..a600537f --- /dev/null +++ b/internal/profile/cloud_base_test.go @@ -0,0 +1,45 @@ +package profile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// applyBase inherits cloud sources when the override omits them, mirroring +// linked_repos: a nil slice means "field absent → take base"; an +// empty-but-non-nil slice is a deliberate clear. +func TestApplyBase_InheritsCloudWhenOverrideOmits(t *testing.T) { + t.Parallel() + override := &Profile{ + Base: "default", + Name: "child", + } + // default ships no cloud sources, so prime the resolved base in memory + // via a direct merge against a hand-built base to exercise the field. + base := &Profile{ + Cloud: []CloudSource{{Alias: "base-gcp", Provider: "gcp", AssumedIdentity: "ro@base.iam.gserviceaccount.com"}}, + } + mergeCloud(override, base) + require.Len(t, override.Cloud, 1) + assert.Equal(t, "base-gcp", override.Cloud[0].Alias) +} + +func TestApplyBase_OverrideCloudWins(t *testing.T) { + t.Parallel() + override := &Profile{ + Cloud: []CloudSource{{ + Alias: "child-aws", + Provider: "aws", + SourceProfile: "sso", + Accounts: []CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}, + }}, + } + base := &Profile{ + Cloud: []CloudSource{{Alias: "base-gcp", Provider: "gcp"}}, + } + mergeCloud(override, base) + require.Len(t, override.Cloud, 1) + assert.Equal(t, "child-aws", override.Cloud[0].Alias, "override cloud must win over base") +} diff --git a/internal/profile/embed.go b/internal/profile/embed.go index 56771c99..8cb78699 100644 --- a/internal/profile/embed.go +++ b/internal/profile/embed.go @@ -172,6 +172,24 @@ func LoadPath(ref string) (*Profile, error) { p.KindsPath = abs } + // command_allowlist_path is documented as relative to this profile.yaml, but + // the cloud MCP subprocess reads it against a session-scoped cwd. Resolve a + // relative override against the profile dir (and absolutize) so the injected + // env points at the file regardless of the child's cwd. Done before + // applyBase so it only touches sources declared in this file; base cloud + // sources come from an embedded profile and carry no filesystem paths. + for i := range p.Cloud { + rel := p.Cloud[i].CommandAllowlistPath + if rel == "" || filepath.IsAbs(rel) { + continue + } + abs, err := filepath.Abs(filepath.Join(dir, rel)) + if err != nil { + return nil, fmt.Errorf("absolutize command_allowlist_path %s: %w", rel, err) + } + p.Cloud[i].CommandAllowlistPath = abs + } + p, err = applyBase(p) if err != nil { return nil, err @@ -291,6 +309,7 @@ func applyBase(override *Profile) (*Profile, error) { if override.InvestigationInputs == nil { override.InvestigationInputs = base.InvestigationInputs } + mergeCloud(override, base) // Prompts: per-file fallback. If override is missing a key, fall back // to base's content for that key. @@ -309,3 +328,12 @@ func applyBase(override *Profile) (*Profile, error) { return override, nil } + +// mergeCloud applies the cloud-source field's replace-on-presence rule: a nil +// override slice inherits the base's sources; an empty-but-non-nil slice is a +// deliberate clear that wins. Mirrors linked_repos / extra_mcps. +func mergeCloud(override, base *Profile) { + if override.Cloud == nil { + override.Cloud = base.Cloud + } +} diff --git a/internal/profile/paths.go b/internal/profile/paths.go index e0403295..d5ef4fa5 100644 --- a/internal/profile/paths.go +++ b/internal/profile/paths.go @@ -60,6 +60,12 @@ type Paths struct { // per-repo clones used for architecture summaries / draft_pr. GitCacheDir string `yaml:"git_cache_dir"` + // CloudCacheDir holds the triagent-owned AWS config the cloud MCP + // reads via AWS_CONFIG_FILE. triagent generates a `/config` here + // (a copy of the operator's ~/.aws/config plus the managed assume-role + // profiles) rather than editing ~/.aws/config in place. + CloudCacheDir string `yaml:"cloud_cache_dir"` + // UserReposFile is the per-machine YAML the manage-repos UI // writes additions to. The launcher reads it at startup to // reconstruct the linked-repos list. @@ -91,6 +97,7 @@ var defaultPathTemplates = Paths{ CodefixProposalsDir: "${XDG_CONFIG_HOME}/triagent/${PROFILE_NAME}/codefix-proposals", WikiProposalsDir: "${XDG_CONFIG_HOME}/triagent/${PROFILE_NAME}/wiki-proposals", GitCacheDir: "${XDG_CACHE_HOME}/triagent-mcp/${PROFILE_NAME}/git", + CloudCacheDir: "${XDG_CACHE_HOME}/triagent-mcp/${PROFILE_NAME}/aws", UserReposFile: "${XDG_CONFIG_HOME}/triagent/${PROFILE_NAME}/user_repos.yaml", UserWatchesFile: "${XDG_CONFIG_HOME}/triagent/${PROFILE_NAME}/user_watches.yaml", } @@ -128,7 +135,7 @@ func (p Paths) Resolve(profileName string) (Paths, error) { p.UpstreamPlaybooksDir, p.SystemPlaybooksDir, p.UserPlaybooksDir, p.WikiDir, p.SessionsRoot, p.UpstreamSessionsDir, p.SessionsProposalsDir, p.CodefixProposalsDir, p.WikiProposalsDir, - p.GitCacheDir, p.UserReposFile, p.UserWatchesFile, + p.GitCacheDir, p.CloudCacheDir, p.UserReposFile, p.UserWatchesFile, } { if strings.Contains(s, "${PROFILE_NAME}") && profileName == "" { return Paths{}, fmt.Errorf("path %q references ${PROFILE_NAME} but the loaded profile has no name", s) @@ -152,6 +159,7 @@ func (p Paths) Resolve(profileName string) (Paths, error) { CodefixProposalsDir: expand(p.CodefixProposalsDir), WikiProposalsDir: expand(p.WikiProposalsDir), GitCacheDir: expand(p.GitCacheDir), + CloudCacheDir: expand(p.CloudCacheDir), UserReposFile: expand(p.UserReposFile), UserWatchesFile: expand(p.UserWatchesFile), }, nil @@ -178,6 +186,7 @@ func mergePaths(base, override Paths) Paths { CodefixProposalsDir: pick(override.CodefixProposalsDir, base.CodefixProposalsDir), WikiProposalsDir: pick(override.WikiProposalsDir, base.WikiProposalsDir), GitCacheDir: pick(override.GitCacheDir, base.GitCacheDir), + CloudCacheDir: pick(override.CloudCacheDir, base.CloudCacheDir), UserReposFile: pick(override.UserReposFile, base.UserReposFile), UserWatchesFile: pick(override.UserWatchesFile, base.UserWatchesFile), } diff --git a/internal/profile/paths_test.go b/internal/profile/paths_test.go index 0f872bc7..741ad099 100644 --- a/internal/profile/paths_test.go +++ b/internal/profile/paths_test.go @@ -102,6 +102,23 @@ func TestPathsResolveExpandsProfileName(t *testing.T) { } } +// CloudCacheDir defaults to the per-profile cache slot (like GitCacheDir) when +// a profile leaves it unset, so the AWS provider writes its managed config under +// the profile's own namespace rather than the operator's ~/.aws/config. +func TestPathsResolveCloudCacheDirDefault(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/cfg") + t.Setenv("XDG_CACHE_HOME", "/cache") + t.Setenv("HOME", "/home/me") + + out, err := (profile.Paths{}).Resolve("camunda") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if out.CloudCacheDir != "/cache/triagent-mcp/camunda/aws" { + t.Errorf("CloudCacheDir=%q", out.CloudCacheDir) + } +} + // An empty profile name with a ${PROFILE_NAME} token is a load-time // programming error (every profile carries a name). Surface it loudly // instead of silently producing a path with a doubled separator. @@ -187,6 +204,7 @@ func TestPathsResolveFillsDefaultsWhenEmpty(t *testing.T) { CodefixProposalsDir: "/cfg/triagent/camunda/codefix-proposals", WikiProposalsDir: "/cfg/triagent/camunda/wiki-proposals", GitCacheDir: "/cache/triagent-mcp/camunda/git", + CloudCacheDir: "/cache/triagent-mcp/camunda/aws", UserReposFile: "/cfg/triagent/camunda/user_repos.yaml", UserWatchesFile: "/cfg/triagent/camunda/user_watches.yaml", } diff --git a/internal/profile/profile.go b/internal/profile/profile.go index ea6579ba..bdf58fa5 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -8,6 +8,8 @@ import ( "io" "gopkg.in/yaml.v3" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) // Profile is the in-memory shape of profile.yaml. Field tags match the @@ -28,6 +30,7 @@ type Profile struct { LinkedRepos []LinkedRepo `yaml:"linked_repos"` ExtraMCPs []ExtraMCP `yaml:"extra_mcps"` InvestigationInputs []InvestigationInput `yaml:"investigation_inputs"` + Cloud []CloudSource `yaml:"cloud"` // PromptFiles declares prompt overrides by filename → path (relative // to the profile.yaml's directory). Loaded into Prompts at load time @@ -147,6 +150,61 @@ type ExtraMCP struct { AllowedTools []string `yaml:"allowed_tools,omitempty"` } +// CloudSource is a deployment-configured, read-only cloud connection the +// launcher wires per session as a triagent-cloud- MCP server. It is +// configured in the profile, never entered in the connections panel: the agent +// can read the pinned identity but cannot select or escalate it. +// +// The pinned identity is provider-specific. GCP spans its projects with one +// impersonated service account (AssumedIdentity, shown in the connections +// panel), selecting among them by Scope.Projects. AWS has no single assumed +// identity: it pins a list of accounts, each {account_id, role_arn}, and +// triagent generates a per-account assume-role profile layering each role over +// SourceProfile; the agent selects among them via set_active_target, and each +// account validates against its own role_arn. A single-account AWS source is +// simply a one-entry Accounts list — there is no separate single-account shape. +type CloudSource struct { + Alias string `yaml:"alias"` + Provider string `yaml:"provider"` // "gcp" | "aws" + // AssumedIdentity is the gcp impersonated service-account email. Required for + // gcp; must be empty for aws (whose identity is per-account, in Accounts). + AssumedIdentity string `yaml:"assumed_identity,omitempty"` + // SourceProfile is the operator's SSO base profile the generated per-account + // assume-role profiles layer their role_arn over. Required for aws. + SourceProfile string `yaml:"source_profile,omitempty"` + // Accounts is the aws account set, each entry a {account_id, role_arn, tags} + // that becomes a generated assume-role profile the agent may make active. + // Required for aws (a single-account source is a one-entry list); unused by gcp. + Accounts []CloudAccount `yaml:"accounts,omitempty"` + // Projects is the gcp selectable project set, each a {id, tags}. Optional: + // when empty the agent selects among the projects list_inventory surfaces + // live. Unused by aws. + Projects []CloudProject `yaml:"projects,omitempty"` + Scope cloud.ScopeAllowlist `yaml:"scope,omitempty"` + // CommandAllowlistPath points the cloud MCP at a run_cli allowlist override + // file; empty uses the provider's embedded default. A relative path resolves + // against the profile.yaml's directory at load time (absolutized so the MCP + // subprocess can read it from any cwd). + CommandAllowlistPath string `yaml:"command_allowlist_path,omitempty"` +} + +// CloudAccount is one aws account in a cloud source: the account id the agent +// selects by, the read-only role_arn triagent assumes into it from the source's +// SourceProfile, and the deployment's free-form tags surfaced by list_inventory +// so the agent can judge which account an investigation belongs to. +type CloudAccount struct { + AccountID string `yaml:"account_id" json:"account_id"` + RoleARN string `yaml:"role_arn" json:"role_arn"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` +} + +// CloudProject is one gcp project the agent may select: the project id and the +// deployment's free-form tags surfaced by list_inventory. +type CloudProject struct { + ID string `yaml:"id" json:"id"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` +} + type InvestigationInput struct { ID string `yaml:"id"` Label string `yaml:"label"` diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 2969251b..10e58004 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -237,6 +237,221 @@ func TestValidateMissingTeleportFields(t *testing.T) { } } +func validCloudBase() *profile.Profile { + return &profile.Profile{ + Name: "x", + Auth: profile.Auth{Kind: "kubeconfig"}, + Playbooks: profile.Playbooks{Entrypoint: "a", Closing: "b"}, + } +} + +func TestValidateCloudSourcesOK(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@proj.iam.gserviceaccount.com"}, + awsAccountsBase(), + } + assert.NoError(t, p.Validate(), "a valid multi-source cloud profile must validate clean") +} + +func TestValidateCloudDuplicateAlias(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "dup", Provider: "gcp", AssumedIdentity: "ro@proj.iam.gserviceaccount.com"}, + {Alias: "dup", Provider: "aws", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, + } + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") + assert.Contains(t, err.Error(), "dup") +} + +func TestValidateCloudEmptyAlias(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Provider: "gcp", AssumedIdentity: "ro@proj.iam.gserviceaccount.com"}, + } + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "alias") +} + +func TestValidateCloudUnknownProvider(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "x", Provider: "azure", AssumedIdentity: "whatever"}, + } + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "provider") + assert.Contains(t, err.Error(), "azure") +} + +func TestValidateCloudMissingIdentity(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "x", Provider: "gcp"}, + } + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "assumed_identity") +} + +func TestValidateCloudAWSMissingAccounts(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "x", Provider: "aws", SourceProfile: "sso"}, + } + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "accounts") +} + +func TestValidateCloudGCPProjectsOK(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com", Projects: []profile.CloudProject{ + {ID: "prod-a", Tags: []string{"prod"}}, + {ID: "prod-b"}, + }}, + } + assert.NoError(t, p.Validate(), "a gcp source with valid projects must validate clean") +} + +func TestValidateCloudGCPProjectDuplicateID(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "x", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com", Projects: []profile.CloudProject{ + {ID: "dup"}, {ID: "dup"}, + }}, + } + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "projects") + assert.Contains(t, err.Error(), "duplicate") +} + +func TestValidateCloudGCPRejectsAWSFields(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "x", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com", SourceProfile: "sso"}, + } + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "aws-only") +} + +func TestValidateCloudAWSRejectsProjects(t *testing.T) { + p := validCloudBase() + src := awsAccountsBase() + src.Projects = []profile.CloudProject{{ID: "prod-a"}} + p.Cloud = []profile.CloudSource{src} + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "projects") + assert.Contains(t, err.Error(), "gcp-only") +} + +func TestValidateCloudAWSRejectsAssumedIdentity(t *testing.T) { + p := validCloudBase() + src := awsAccountsBase() + src.AssumedIdentity = "arn:aws:iam::111111111111:role/triage-readonly" + p.Cloud = []profile.CloudSource{src} + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "assumed_identity") + assert.Contains(t, err.Error(), "aws") +} + +const awsAccountsYAML = ` +name: example +description: test profile +auth: + kind: kubeconfig +playbooks: + entrypoint: a + closing: b +cloud: + - alias: prod-aws + provider: aws + source_profile: sso-admin + accounts: + - {account_id: "111111111111", role_arn: "arn:aws:iam::111111111111:role/triage-readonly"} + - {account_id: "222222222222", role_arn: "arn:aws:iam::222222222222:role/triage-readonly"} +` + +func TestCloudSourceAWSAccounts(t *testing.T) { + p, err := profile.Parse(strings.NewReader(awsAccountsYAML)) + require.NoError(t, err) + require.NoError(t, p.Validate()) + require.Len(t, p.Cloud[0].Accounts, 2) + assert.Equal(t, "sso-admin", p.Cloud[0].SourceProfile) + assert.Equal(t, "111111111111", p.Cloud[0].Accounts[0].AccountID) + assert.Equal(t, "arn:aws:iam::222222222222:role/triage-readonly", p.Cloud[0].Accounts[1].RoleARN) +} + +// awsAccountsBase is a valid multi-account aws source the negative-case tests +// each break in exactly one way. +func awsAccountsBase() profile.CloudSource { + return profile.CloudSource{ + Alias: "prod-aws", + Provider: "aws", + SourceProfile: "sso-admin", + Accounts: []profile.CloudAccount{ + {AccountID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/triage-readonly"}, + {AccountID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/triage-readonly"}, + }, + } +} + +func TestValidateCloudAWSAccountsOK(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{awsAccountsBase()} + assert.NoError(t, p.Validate(), "an aws source with source_profile + unique accounts must validate clean") +} + +func TestValidateCloudAWSAccountsRequireSourceProfile(t *testing.T) { + p := validCloudBase() + src := awsAccountsBase() + src.SourceProfile = "" + p.Cloud = []profile.CloudSource{src} + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "source_profile") +} + +func TestValidateCloudAWSAccountsDuplicateID(t *testing.T) { + p := validCloudBase() + src := awsAccountsBase() + src.Accounts[1].AccountID = src.Accounts[0].AccountID + p.Cloud = []profile.CloudSource{src} + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "account_id") + assert.Contains(t, err.Error(), "duplicate") +} + +func TestValidateCloudAWSAccountsDuplicateRoleARN(t *testing.T) { + p := validCloudBase() + src := awsAccountsBase() + src.Accounts[1].RoleARN = src.Accounts[0].RoleARN + p.Cloud = []profile.CloudSource{src} + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "role_arn") + assert.Contains(t, err.Error(), "duplicate") +} + +func TestValidateCloudAWSAccountsEmptyFields(t *testing.T) { + p := validCloudBase() + src := awsAccountsBase() + src.Accounts[0].AccountID = "" + p.Cloud = []profile.CloudSource{src} + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "account_id") +} + func TestDefaultProfilePromptsPopulated(t *testing.T) { p, err := profile.LoadEmbedded("default") if err != nil { @@ -520,6 +735,47 @@ kinds_file: kinds.json } } +func TestLoadPath_CommandAllowlistPathAbsoluteFromRelativeRef(t *testing.T) { + // command_allowlist_path is documented as relative to profile.yaml, but the + // cloud MCP subprocess os.ReadFiles it against a session-scoped cwd. Load + // must absolutize a relative override so the injected env points at the file + // regardless of the child's cwd; an absolute override passes through. + root := t.TempDir() + profDir := filepath.Join(root, "test-profile", "camunda") + require.NoError(t, os.MkdirAll(profDir, 0o755)) + yaml := `name: camunda +base: default +auth: + kind: kubeconfig +playbooks: + entrypoint: investigation + closing: capture_offer +cloud: + - alias: prod-gcp + provider: gcp + assumed_identity: ro@proj.iam.gserviceaccount.com + command_allowlist_path: allow/gcp.json + - alias: prod-aws + provider: aws + source_profile: sso-admin + accounts: + - {account_id: "111122223333", role_arn: "arn:aws:iam::111122223333:role/ro"} + command_allowlist_path: /etc/triagent/aws-allow.json +` + require.NoError(t, os.WriteFile(filepath.Join(profDir, "profile.yaml"), []byte(yaml), 0o600)) + + t.Chdir(root) + p, err := profile.Load("test-profile/camunda/profile.yaml") + require.NoError(t, err) + require.Len(t, p.Cloud, 2) + + assert.Equal(t, filepath.Join(profDir, "allow", "gcp.json"), p.Cloud[0].CommandAllowlistPath, + "a relative command_allowlist_path resolves against the profile dir") + assert.True(t, filepath.IsAbs(p.Cloud[0].CommandAllowlistPath)) + assert.Equal(t, "/etc/triagent/aws-allow.json", p.Cloud[1].CommandAllowlistPath, + "an absolute command_allowlist_path passes through unchanged") +} + func TestLoadPath_KindsFileMissingErrors(t *testing.T) { // Declaring a kinds_file that doesn't exist on disk is a hard error, // not a silent skip — operators should know their override didn't @@ -662,3 +918,53 @@ func TestProfile_ApplyDefaults_PreservesExplicitModels(t *testing.T) { assert.Equal(t, "x", p.Models.Investigation) assert.Equal(t, "y", p.Models.Subagent) } + +func TestProfile_ParsesCloudBlock(t *testing.T) { + t.Parallel() + src := ` +name: example +description: test profile +auth: + kind: kubeconfig +cloud: + - alias: prod-gcp + provider: gcp + assumed_identity: triage-ro@prod.iam.gserviceaccount.com + projects: + - {id: prod-a, tags: [prod, payments]} + - {id: prod-b} + command_allowlist_path: /etc/triagent/gcp-allow.json + - alias: prod-aws + provider: aws + source_profile: sso-admin + accounts: + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-ro", tags: [prod, analytics]} + scope: + regions: + - us-east-1 +` + p, err := profile.Parse(strings.NewReader(src)) + require.NoError(t, err) + require.Len(t, p.Cloud, 2) + + gcp := p.Cloud[0] + assert.Equal(t, "prod-gcp", gcp.Alias) + assert.Equal(t, "gcp", gcp.Provider) + assert.Equal(t, "triage-ro@prod.iam.gserviceaccount.com", gcp.AssumedIdentity) + require.Len(t, gcp.Projects, 2) + assert.Equal(t, "prod-a", gcp.Projects[0].ID) + assert.Equal(t, []string{"prod", "payments"}, gcp.Projects[0].Tags) + assert.Equal(t, "prod-b", gcp.Projects[1].ID) + assert.Empty(t, gcp.Projects[1].Tags) + assert.Equal(t, "/etc/triagent/gcp-allow.json", gcp.CommandAllowlistPath) + + aws := p.Cloud[1] + assert.Equal(t, "prod-aws", aws.Alias) + assert.Equal(t, "aws", aws.Provider) + assert.Empty(t, aws.AssumedIdentity, "aws has no source-level assumed identity") + assert.Equal(t, "sso-admin", aws.SourceProfile) + require.Len(t, aws.Accounts, 1) + assert.Equal(t, "123456789012", aws.Accounts[0].AccountID) + assert.Equal(t, []string{"prod", "analytics"}, aws.Accounts[0].Tags) + assert.Equal(t, []string{"us-east-1"}, aws.Scope.Regions) +} diff --git a/internal/profile/profiles/default/profile.yaml b/internal/profile/profiles/default/profile.yaml index 87714ad7..5009cfc3 100644 --- a/internal/profile/profiles/default/profile.yaml +++ b/internal/profile/profiles/default/profile.yaml @@ -187,6 +187,48 @@ linked_repos: [] # allowed_tools: [mcp__prom-bridge__query] extra_mcps: [] +# Read-only cloud-context sources (GCP / AWS). Each entry attaches a +# `triagent-cloud-` MCP to every investigation so the agent can +# read cloud context (reachability, IAM, GKE/EKS config, logs, audit) +# alongside the cluster. Read-only by construction: the agent runs a +# fixed `gcloud`/`aws` binary against an allowlist, as a pinned identity +# it cannot select or escalate. Configured here, never in the UI. +# +# The identity is pinned, not entered: for gcp the harness impersonates +# `assumed_identity` (a service-account email) via +# CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT off the operator's own +# `gcloud auth login`; for aws it generates a per-account assume-role profile +# (role_arn over the operator's `source_profile`) for each `accounts` entry and +# pins AWS_PROFILE to the active account, checking the resolved caller against +# that account's role_arn. `scope.regions` is the only argv-enforced axis: a +# `--region`/`--zone` outside it is rejected before the command runs (an empty +# axis is unconstrained). Projects/accounts are selected via the `projects`/ +# `accounts` lists and `set_active_target`, not by `scope`; `scope.accounts` is +# informational only. `command_allowlist_path` overrides the provider's embedded +# read-only default. +# +# Setup (one-time, per deployment): grant the operator +# roles/iam.serviceAccountTokenCreator on the gcp SA, or grant the aws +# read-only role(s) and an SSO base profile. See: +# https://github.com/sourcehawk/triagent/blob/main/docs/content/cloud-providers.md +# Example: +# cloud: +# - alias: prod-gcp +# provider: gcp +# assumed_identity: triage-readonly@prod.iam.gserviceaccount.com +# projects: # selectable projects + tags (list_inventory returns the tags) +# - {id: prod-platform, tags: [prod, payments]} +# scope: +# regions: [us-central1] +# - alias: prod-aws +# provider: aws # no assumed_identity; identity is per-account +# source_profile: sso-admin # operator's SSO base +# accounts: # single-account = a one-entry list +# - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly", tags: [prod, payments]} +# scope: +# accounts: ["123456789012"] +# regions: [eu-west-1] + # Inline prompt overrides. Map of . Lets you keep a flat profile # dir instead of nesting under `prompts/`. The conventional diff --git a/internal/profile/validate.go b/internal/profile/validate.go index 463bcc93..210b7abf 100644 --- a/internal/profile/validate.go +++ b/internal/profile/validate.go @@ -59,8 +59,100 @@ func (p *Profile) Validate() error { } } + // Cloud sources are wired per session as triagent-cloud- MCP servers + // keyed by alias, so a duplicate or empty alias silently overwrites another + // server's entry; an unknown provider or a malformed identity shape reaches + // preflight as a broken connection. Catch all of it here. The identity shape + // is provider-specific: gcp pins one impersonated service account + // (assumed_identity); aws pins a per-account role set (accounts + + // source_profile) and has no single assumed identity. + seenAliases := map[string]bool{} + for i, c := range p.Cloud { + if c.Alias == "" { + errs = append(errs, fmt.Sprintf("cloud[%d].alias: required", i)) + } else if seenAliases[c.Alias] { + errs = append(errs, fmt.Sprintf("cloud[%d].alias: duplicate %q", i, c.Alias)) + } + seenAliases[c.Alias] = true + + switch c.Provider { + case "gcp": + if c.AssumedIdentity == "" { + errs = append(errs, fmt.Sprintf("cloud[%d].assumed_identity: required when provider=gcp", i)) + } + if len(c.Accounts) > 0 || c.SourceProfile != "" { + errs = append(errs, fmt.Sprintf("cloud[%d]: accounts/source_profile are aws-only; gcp selects projects", i)) + } + errs = append(errs, validateGCPProjects(i, c)...) + case "aws": + if c.AssumedIdentity != "" { + errs = append(errs, fmt.Sprintf("cloud[%d].assumed_identity: must be empty when provider=aws (each account pins its own role_arn)", i)) + } + if len(c.Projects) > 0 { + errs = append(errs, fmt.Sprintf("cloud[%d].projects: gcp-only; an aws source selects accounts", i)) + } + errs = append(errs, validateAWSCredentials(i, c)...) + case "": + errs = append(errs, fmt.Sprintf("cloud[%d].provider: required (supported: gcp, aws)", i)) + default: + errs = append(errs, fmt.Sprintf("cloud[%d].provider: unknown %q (supported: gcp, aws)", i, c.Provider)) + } + } + if len(errs) == 0 { return nil } return errors.New("profile " + p.Name + " invalid:\n - " + strings.Join(errs, "\n - ")) } + +// validateGCPProjects checks the optional gcp project set: each entry needs a +// non-empty id, unique across the source. Tags are free-form and unchecked. An +// empty set is valid — the agent selects among the live-listed projects instead. +func validateGCPProjects(i int, c CloudSource) []string { + var errs []string + seen := map[string]bool{} + for j, pr := range c.Projects { + switch { + case pr.ID == "": + errs = append(errs, fmt.Sprintf("cloud[%d].projects[%d].id: required", i, j)) + case seen[pr.ID]: + errs = append(errs, fmt.Sprintf("cloud[%d].projects[%d].id: duplicate %q", i, j, pr.ID)) + } + seen[pr.ID] = true + } + return errs +} + +// validateAWSCredentials checks the aws credential shape: an accounts list (a +// single-account source is a one-entry list) plus the operator's source_profile +// the generated per-account assume-role profiles layer over. Account ids and +// role_arns are each required and unique across the source. +func validateAWSCredentials(i int, c CloudSource) []string { + var errs []string + if len(c.Accounts) == 0 { + errs = append(errs, fmt.Sprintf("cloud[%d].accounts: required when provider=aws (a single-account source is a one-entry list)", i)) + } + if c.SourceProfile == "" { + errs = append(errs, fmt.Sprintf("cloud[%d].source_profile: required when provider=aws", i)) + } + seenIDs := map[string]bool{} + seenARNs := map[string]bool{} + for j, a := range c.Accounts { + switch { + case a.AccountID == "": + errs = append(errs, fmt.Sprintf("cloud[%d].accounts[%d].account_id: required", i, j)) + case seenIDs[a.AccountID]: + errs = append(errs, fmt.Sprintf("cloud[%d].accounts[%d].account_id: duplicate %q", i, j, a.AccountID)) + } + seenIDs[a.AccountID] = true + + switch { + case a.RoleARN == "": + errs = append(errs, fmt.Sprintf("cloud[%d].accounts[%d].role_arn: required", i, j)) + case seenARNs[a.RoleARN]: + errs = append(errs, fmt.Sprintf("cloud[%d].accounts[%d].role_arn: duplicate %q", i, j, a.RoleARN)) + } + seenARNs[a.RoleARN] = true + } + return errs +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index b96c3ba9..1a41bcd3 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/sourcehawk/triagent/internal/repos" "github.com/sourcehawk/triagent/internal/sessions" "github.com/sourcehawk/triagent/internal/watches" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) // apiHandlers carries the dependencies the JSON handlers need without @@ -64,6 +65,11 @@ type apiHandlers struct { // is used. preflightFn func(preflight.Options) (*preflight.Result, error) + // cloudProbe runs the read-only identity probe for one profile cloud + // source when building the /api/connections cloud array. Nil uses the + // real prober (providers.ProbeSource); tests inject a stub. + cloudProbe func(context.Context, profile.CloudSource) cloud.IdentityStatus + // sessionFn builds the live claude session after rehydrate resolves // the new external state. Tests inject a stub so the test process // does not need a real `claude` binary on PATH. When nil, the @@ -421,6 +427,7 @@ func (a *apiHandlers) handlePreflight(w http.ResponseWriter, r *http.Request) { DocsServerName: a.opts.DocsServerName, LinkedRepos: linked, GitCacheDir: a.opts.GitCacheDir, + CloudCacheDir: a.opts.CloudCacheDir, UserPlaybooksDir: a.opts.UserPlaybooksDir, PluginPlaybooksDir: a.opts.PluginPlaybooksDir, SystemPlaybooksDir: a.opts.SystemPlaybooksDir, diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index 51dcc622..20386afa 100644 --- a/internal/server/handlers_connections.go +++ b/internal/server/handlers_connections.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "time" "github.com/sourcehawk/triagent/internal/connections" + "github.com/sourcehawk/triagent/internal/preflight" ) // Connection-management endpoints. The panel reads /api/connections to @@ -25,12 +27,36 @@ import ( // endpoint. type connectionsResponse struct { connections.Status - SlackChannelPrefix string `json:"slack_channel_prefix"` + SlackChannelPrefix string `json:"slack_channel_prefix"` + Cloud []cloudConnection `json:"cloud"` +} + +// cloudConnection is the read-only view of one profile cloud source: its alias, +// the pinned identity, and the request-time probe result. The alias keys the +// triagent-cloud- MCP and distinguishes two sources that share a +// provider and identity but differ in scope. It carries no edit affordance — +// cloud is configured in the profile, never entered in the panel. +type cloudConnection struct { + Alias string `json:"alias"` + Provider string `json:"provider"` + // Each pill renders the same two-line shape — a principal and the reach it + // grants — with provider-specific content. gcp: AssumedIdentity (the + // impersonated service account) over Projects (the source's `projects:` + // selectable set; empty means the agent selects among live-listed projects). + // aws: SourceProfile (the operator's SSO base) over Accounts (the account + // ids the agent may select among). + AssumedIdentity string `json:"assumed_identity,omitempty"` + Projects []string `json:"projects,omitempty"` + SourceProfile string `json:"source_profile,omitempty"` + Accounts []string `json:"accounts,omitempty"` + Valid bool `json:"valid"` + Hint string `json:"hint,omitempty"` } // connectionsResp builds the full response body for all /api/connections -// endpoints, merging connection status with profile boot config. -func (a *apiHandlers) connectionsResp() connectionsResponse { +// endpoints, merging connection status with profile boot config and the +// request-time cloud identity probe. +func (a *apiHandlers) connectionsResp(ctx context.Context) connectionsResponse { var prefix string if a.prof != nil { prefix = a.prof.Slack.ChannelPrefix @@ -38,11 +64,69 @@ func (a *apiHandlers) connectionsResp() connectionsResponse { return connectionsResponse{ Status: a.connections.Status(), SlackChannelPrefix: prefix, + Cloud: a.cloudConnections(ctx), + } +} + +// cloudConnections probes each profile cloud source at request time and projects +// the result into the read-only panel view. Returns an empty slice when no +// profile or no cloud sources are configured, so the JSON field is always an +// array. The probe degrades, never blocks: an invalid source still appears, with +// its hint, so the operator can fix a stale credential before starting a session. +func (a *apiHandlers) cloudConnections(ctx context.Context) []cloudConnection { + if a.prof == nil || len(a.prof.Cloud) == 0 { + return []cloudConnection{} + } + probe := a.cloudProbe + if probe == nil { + probe = preflight.DefaultCloudProbe + } + out := make([]cloudConnection, 0, len(a.prof.Cloud)) + for _, src := range a.prof.Cloud { + st := probe(ctx, src) + // A probe that fails before resolving the provider leaves it blank; fall + // back to the configured value so a degraded source still renders. The + // alias is always the source's. + provider := st.Provider + if provider == "" { + provider = src.Provider + } + conn := cloudConnection{ + Alias: src.Alias, + Provider: provider, + Valid: st.Valid, + Hint: st.Hint, + } + // Each provider fills the same principal + reach shape differently. gcp: + // the one impersonated service account (the probe's resolved value, or the + // configured one when the probe degraded before resolving it) over its + // allowlisted projects. aws: the operator's SSO base profile over the + // account set it spans. + switch src.Provider { + case "aws": + conn.SourceProfile = src.SourceProfile + conn.Accounts = make([]string, 0, len(src.Accounts)) + for _, acct := range src.Accounts { + conn.Accounts = append(conn.Accounts, acct.AccountID) + } + default: + conn.AssumedIdentity = st.AssumedIdentity + if conn.AssumedIdentity == "" { + conn.AssumedIdentity = src.AssumedIdentity + } + conn.Projects = make([]string, 0, len(src.Projects)) + for _, pr := range src.Projects { + conn.Projects = append(conn.Projects, pr.ID) + } + } + out = append(out, conn) } + return out } // handleGetConnections returns which integrations have a usable token, plus -// profile boot config (slack_channel_prefix). +// profile boot config (slack_channel_prefix) and the request-time cloud +// identity probe. // // GET /api/connections func (a *apiHandlers) handleGetConnections(w http.ResponseWriter, r *http.Request) { @@ -50,7 +134,7 @@ func (a *apiHandlers) handleGetConnections(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } - writeJSON(w, http.StatusOK, a.connectionsResp()) + writeJSON(w, http.StatusOK, a.connectionsResp(r.Context())) } // handlePutSlackToken validates a Slack token via auth.test, then persists @@ -80,7 +164,7 @@ func (a *apiHandlers) handlePutSlackToken(w http.ResponseWriter, r *http.Request writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, a.connectionsResp()) + writeJSON(w, http.StatusOK, a.connectionsResp(r.Context())) } // handlePutIncidentioToken validates an incident.io API key by calling @@ -108,7 +192,7 @@ func (a *apiHandlers) handlePutIncidentioToken(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, a.connectionsResp()) + writeJSON(w, http.StatusOK, a.connectionsResp(r.Context())) } // handleDeleteConnection clears the token for the given kind. @@ -124,7 +208,7 @@ func (a *apiHandlers) handleDeleteConnection(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, a.connectionsResp()) + writeJSON(w, http.StatusOK, a.connectionsResp(r.Context())) } // channelDTO is the redacted shape returned by /api/slack/channels: just diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go index 0c36efa5..972279cd 100644 --- a/internal/server/handlers_connections_test.go +++ b/internal/server/handlers_connections_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "io" "net/http" @@ -10,6 +11,7 @@ import ( "github.com/sourcehawk/triagent/internal/connections" "github.com/sourcehawk/triagent/internal/profile" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -285,3 +287,137 @@ func TestPutSlackToken_PersistsWorkspaceURLFromAuthTest(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://example.slack.com", wsURL, "trailing slash must be stripped") } + +func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { + t.Parallel() + prof := &profile.Profile{ + Cloud: []profile.CloudSource{ + {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com", Projects: []profile.CloudProject{{ID: "prod-platform", Tags: []string{"prod"}}, {ID: "prod-data"}}}, + {Alias: "prod-aws", Provider: "aws", SourceProfile: "sso-admin", Accounts: []profile.CloudAccount{ + {AccountID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/ro"}, + {AccountID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/ro"}, + }}, + }, + } + a := &apiHandlers{ + connections: connections.NewWithDir(t.TempDir()), + prof: prof, + cloudProbe: func(_ context.Context, src profile.CloudSource) cloud.IdentityStatus { + if src.Provider == "gcp" { + return cloud.IdentityStatus{Provider: "gcp", AssumedIdentity: src.AssumedIdentity, Valid: true} + } + return cloud.IdentityStatus{Provider: "aws", Valid: false, Hint: "run: aws sso login"} + }, + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/connections", nil) + a.handleGetConnections(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body) + + var resp struct { + Cloud []struct { + Alias string `json:"alias"` + Provider string `json:"provider"` + AssumedIdentity string `json:"assumed_identity"` + Projects []string `json:"projects"` + Accounts []string `json:"accounts"` + SourceProfile string `json:"source_profile"` + Valid bool `json:"valid"` + Hint string `json:"hint"` + } `json:"cloud"` + } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + require.Len(t, resp.Cloud, 2) + + // gcp: the impersonated identity over its allowlisted projects. + assert.Equal(t, "prod-gcp", resp.Cloud[0].Alias) + assert.Equal(t, "gcp", resp.Cloud[0].Provider) + assert.Equal(t, "ro@p.iam.gserviceaccount.com", resp.Cloud[0].AssumedIdentity) + assert.Equal(t, []string{"prod-platform", "prod-data"}, resp.Cloud[0].Projects) + assert.Empty(t, resp.Cloud[0].Accounts) + assert.True(t, resp.Cloud[0].Valid) + + // aws: the SSO base profile over its account set, never a single identity. + assert.Equal(t, "prod-aws", resp.Cloud[1].Alias) + assert.Equal(t, "aws", resp.Cloud[1].Provider) + assert.Empty(t, resp.Cloud[1].AssumedIdentity, "aws has no single assumed identity") + assert.Empty(t, resp.Cloud[1].Projects) + assert.Equal(t, []string{"111111111111", "222222222222"}, resp.Cloud[1].Accounts) + assert.Equal(t, "sso-admin", resp.Cloud[1].SourceProfile) + assert.False(t, resp.Cloud[1].Valid) + assert.Equal(t, "run: aws sso login", resp.Cloud[1].Hint) +} + +// TestGetConnections_DegradedSource_KeepsConfiguredDetail asserts that when the +// probe fails before resolving anything, each source still renders its +// configured detail so the operator sees what was pinned alongside valid:false +// and the hint: gcp falls back to its configured assumed_identity, and aws shows +// its account set + SSO base (which always come from the profile, not the probe). +func TestGetConnections_DegradedSource_KeepsConfiguredDetail(t *testing.T) { + t.Parallel() + prof := &profile.Profile{ + Cloud: []profile.CloudSource{ + {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com"}, + {Alias: "prod-aws", Provider: "aws", SourceProfile: "sso-admin", Accounts: []profile.CloudAccount{{AccountID: "123456789012", RoleARN: "arn:aws:iam::123456789012:role/ro"}}}, + }, + } + a := &apiHandlers{ + connections: connections.NewWithDir(t.TempDir()), + prof: prof, + // Probe failed before resolving anything: the status carries only the + // failure signal, no provider or identity. + cloudProbe: func(_ context.Context, _ profile.CloudSource) cloud.IdentityStatus { + return cloud.IdentityStatus{Valid: false, Hint: "re-authenticate"} + }, + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/connections", nil) + a.handleGetConnections(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body) + + var resp struct { + Cloud []struct { + Alias string `json:"alias"` + Provider string `json:"provider"` + AssumedIdentity string `json:"assumed_identity"` + Accounts []string `json:"accounts"` + SourceProfile string `json:"source_profile"` + Valid bool `json:"valid"` + Hint string `json:"hint"` + } `json:"cloud"` + } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + require.Len(t, resp.Cloud, 2) + + assert.Equal(t, "gcp", resp.Cloud[0].Provider, "degraded gcp source falls back to configured provider") + assert.Equal(t, "ro@p.iam.gserviceaccount.com", resp.Cloud[0].AssumedIdentity, "degraded gcp source falls back to configured identity") + assert.False(t, resp.Cloud[0].Valid) + + assert.Equal(t, "aws", resp.Cloud[1].Provider, "degraded aws source falls back to configured provider") + assert.Empty(t, resp.Cloud[1].AssumedIdentity) + assert.Equal(t, []string{"123456789012"}, resp.Cloud[1].Accounts, "degraded aws source still shows its accounts") + assert.Equal(t, "sso-admin", resp.Cloud[1].SourceProfile) + assert.False(t, resp.Cloud[1].Valid) + assert.Equal(t, "re-authenticate", resp.Cloud[1].Hint) +} + +func TestGetConnections_NoCloudSources_OmitsOrEmptyCloud(t *testing.T) { + t.Parallel() + a := newConnectionsAPI(t) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/connections", nil) + a.handleGetConnections(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body) + + body := rr.Body.String() + assert.Contains(t, body, `"cloud":[]`, + "cloud must serialize as an empty array, never null, so clients can always treat it as an array") + + var resp struct { + Cloud []json.RawMessage `json:"cloud"` + } + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + assert.Empty(t, resp.Cloud, "no cloud sources means an empty cloud array") +} diff --git a/internal/server/manager.go b/internal/server/manager.go index 67ebdc72..14b30a58 100644 --- a/internal/server/manager.go +++ b/internal/server/manager.go @@ -15,6 +15,7 @@ import ( "github.com/sourcehawk/triagent/internal/auto" "github.com/sourcehawk/triagent/internal/claude" "github.com/sourcehawk/triagent/internal/editor" + "github.com/sourcehawk/triagent/internal/preflight" "github.com/sourcehawk/triagent/internal/profile" "github.com/sourcehawk/triagent/internal/promforward" "github.com/sourcehawk/triagent/internal/repos" @@ -272,6 +273,15 @@ type autoBackendish interface { SessionID() string } +// CloudMCP is one cloud-context MCP wired into a session, keyed by its wire +// alias (triagent-cloud-, the form the status bar's health/probe +// maps use) and tagged with its provider so the frontend can brand the chip. +// Derived from the profile's cloud sources, which are global to the deployment. +type CloudMCP struct { + Alias string `json:"alias"` + Provider string `json:"provider"` +} + // InvestigationDTO is the JSON shape returned by /api/investigations and // /api/preflight. Snapshotted under the lock so reads are race-free. type InvestigationDTO struct { @@ -289,6 +299,10 @@ type InvestigationDTO struct { SlackMCPEnabled bool `json:"slackMCPEnabled,omitempty"` IncidentioMCPEnabled bool `json:"incidentioMCPEnabled,omitempty"` LinkedRepos []repos.LinkedRepo `json:"linkedRepos,omitempty"` + // CloudMCPs are the cloud-context MCP servers wired into this session, + // derived from the profile's cloud sources. Empty when no cloud sources are + // configured, or when the session carries no profile (e.g. an import). + CloudMCPs []CloudMCP `json:"cloudMcps,omitempty"` CreatedAt time.Time `json:"createdAt"` Started bool `json:"started"` Streaming bool `json:"streaming"` @@ -384,6 +398,7 @@ func (i *Investigation) Snapshot() InvestigationDTO { SlackMCPEnabled: i.SlackMCPEnabled, IncidentioMCPEnabled: i.IncidentioMCPEnabled, LinkedRepos: i.LinkedRepos, + CloudMCPs: cloudMCPsForProfile(i.Profile), CreatedAt: i.CreatedAt, Started: i.started, Streaming: i.streaming, @@ -425,6 +440,24 @@ func (i *Investigation) Snapshot() InvestigationDTO { } } +// cloudMCPsForProfile derives a session's cloud-context MCP wiring from its +// profile's cloud sources. Each source attaches a triagent-cloud- server +// to every investigation, so the set is identical for every session under one +// profile. Returns nil for a profile without cloud sources, or no profile. +func cloudMCPsForProfile(p *profile.Profile) []CloudMCP { + if p == nil || len(p.Cloud) == 0 { + return nil + } + out := make([]CloudMCP, 0, len(p.Cloud)) + for _, src := range p.Cloud { + out = append(out, CloudMCP{ + Alias: preflight.MCPAliasCloudPrefix + src.Alias, + Provider: src.Provider, + }) + } + return out +} + // IsArchived reports whether the investigation has been archived. // Safe to call concurrently. Used by the prom resolver to refuse // re-provisioning a port-forward for a session that's already wound diff --git a/internal/server/manager_test.go b/internal/server/manager_test.go index 9dde4667..38bd7b14 100644 --- a/internal/server/manager_test.go +++ b/internal/server/manager_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/sourcehawk/triagent/internal/auto" + "github.com/sourcehawk/triagent/internal/profile" "github.com/sourcehawk/triagent/internal/promforward" "github.com/sourcehawk/triagent/pkg/mcp/k8s" "github.com/stretchr/testify/assert" @@ -354,6 +355,30 @@ func TestPublishPushState_ReachesMultiplexStream(t *testing.T) { } } +func TestInvestigation_Snapshot_DerivesCloudMCPsFromProfile(t *testing.T) { + // Cloud sources attach a triagent-cloud- MCP to every session, so the + // snapshot derives the wired set from the profile, prefixing each source + // alias into its wire alias and carrying the provider for the chip. + inv := &Investigation{ + ID: "inv-cloud", + CreatedAt: time.Now().UTC(), + Profile: &profile.Profile{ + Cloud: []profile.CloudSource{ + {Alias: "prod-gcp", Provider: "gcp"}, + {Alias: "prod-aws", Provider: "aws"}, + }, + }, + } + dto := inv.Snapshot() + require.Len(t, dto.CloudMCPs, 2) + assert.Equal(t, CloudMCP{Alias: "triagent-cloud-prod-gcp", Provider: "gcp"}, dto.CloudMCPs[0]) + assert.Equal(t, CloudMCP{Alias: "triagent-cloud-prod-aws", Provider: "aws"}, dto.CloudMCPs[1]) + + // A session without a profile (e.g. an imported share bundle) wires no cloud. + bare := &Investigation{ID: "inv-bare", CreatedAt: time.Now().UTC()} + assert.Empty(t, bare.Snapshot().CloudMCPs) +} + func TestInvestigation_Publish_PersistsClaudeSessionID(t *testing.T) { dir := t.TempDir() st := newStore(dir) diff --git a/internal/server/meta.go b/internal/server/meta.go index 01f1c9fe..5b9550aa 100644 --- a/internal/server/meta.go +++ b/internal/server/meta.go @@ -5,6 +5,7 @@ import ( "fmt" "sync" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" "github.com/sourcehawk/triagent/pkg/mcp/git" "github.com/sourcehawk/triagent/pkg/mcp/incidentio" "github.com/sourcehawk/triagent/pkg/mcp/k8s" @@ -107,6 +108,7 @@ func toolCatalog() []MetaTool { specs = append(specs, parallel.ToolSpecs()...) specs = append(specs, prom.ToolSpecs()...) specs = append(specs, teleport.ToolSpecs()...) + specs = append(specs, cloud.ToolSpecs()...) out := make([]MetaTool, 0, len(specs)) for _, s := range specs { ins := make([]MetaToolInput, 0, len(s.Inputs)) diff --git a/internal/server/rehydrate.go b/internal/server/rehydrate.go index aa8a8fe4..7e7f2c96 100644 --- a/internal/server/rehydrate.go +++ b/internal/server/rehydrate.go @@ -81,6 +81,7 @@ func (a *apiHandlers) rehydrate(inv *Investigation) error { DocsServerName: a.opts.DocsServerName, LinkedRepos: linked, GitCacheDir: a.opts.GitCacheDir, + CloudCacheDir: a.opts.CloudCacheDir, UserPlaybooksDir: a.opts.UserPlaybooksDir, PluginPlaybooksDir: a.opts.PluginPlaybooksDir, SystemPlaybooksDir: a.opts.SystemPlaybooksDir, @@ -93,7 +94,11 @@ func (a *apiHandlers) rehydrate(inv *Investigation) error { TelemetryURL: a.telemetryURL, TraceID: inv.ID, TelemetryToken: a.telemetryToken, - Profile: inv.Profile, + // The live launcher profile, not inv.Profile: a restored session comes + // back with a nil Profile, and rehydrate re-derives wiring from current + // launcher state anyway. The profile carries the cloud sources, so using + // the nil one drops the cloud MCP from the regenerated mcp.json. + Profile: a.prof, // Prom config is persisted in metadata.json and restored via // loadInvestigation — pass it through so the rehydrated MCP config // includes the correct prom server entry. @@ -114,6 +119,7 @@ func (a *apiHandlers) rehydrate(inv *Investigation) error { ioEnabled := ioTok != "" inv.mu.Lock() + inv.Profile = a.prof // restore the invariant fresh sessions hold (see opts.Profile above) inv.MCPConfigPath = res.MCPConfigPath inv.DocsPrefix = res.DocsPrefix if res.KubeconfigPath != "" { @@ -135,7 +141,7 @@ func (a *apiHandlers) rehydrate(inv *Investigation) error { LinkedRepos: linked, LaunchCwd: inv.LaunchCwd, KubeconfigPath: inv.KubeconfigPath, - Profile: inv.Profile, + Profile: a.prof, } priorID := inv.ClaudeSessionID inv.mu.Unlock() diff --git a/internal/server/rehydrate_test.go b/internal/server/rehydrate_test.go index b444b6d7..bf1edc33 100644 --- a/internal/server/rehydrate_test.go +++ b/internal/server/rehydrate_test.go @@ -12,6 +12,7 @@ import ( "github.com/sourcehawk/triagent/internal/connections" "github.com/sourcehawk/triagent/internal/preflight" + "github.com/sourcehawk/triagent/internal/profile" "github.com/sourcehawk/triagent/internal/repos" "github.com/sourcehawk/triagent/internal/sessions" ) @@ -34,6 +35,60 @@ func stubSession() func(sessions.Options, string) (investigationSession, error) } } +func TestRehydrate_ThreadsLiveProfileForCloudWiring(t *testing.T) { + // A restored investigation comes back with a nil Profile (Restore does not + // repopulate it), so rehydrate must thread the launcher's live profile — + // which carries the cloud sources — into preflight. Otherwise cloudSources + // sees nothing and the cloud MCP is silently dropped from the regenerated + // mcp.json, so it never spawns on resume. + dir := t.TempDir() + mgr := NewManager(context.Background(), dir) + t.Cleanup(mgr.Shutdown) + live := &profile.Profile{ + Cloud: []profile.CloudSource{{ + Alias: "camunda", + Provider: "aws", + SourceProfile: "operator-test", + Accounts: []profile.CloudAccount{{ + AccountID: "095352988152", + RoleARN: "arn:aws:iam::095352988152:role/triagent-readonly", + }}, + }}, + } + var captured preflight.Options + a := &apiHandlers{ + manager: mgr, + prof: live, + preflightFn: func(opts preflight.Options) (*preflight.Result, error) { + captured = opts + return &preflight.Result{MCPConfigPath: filepath.Join(opts.SessionDir, "mcp.json")}, nil + }, + sessionFn: stubSession(), + } + inv := &Investigation{ + ID: "id", + SessionDir: dir, + Namespace: "ns", + ClaudeSessionID: "sess", + LaunchCwd: dir, + needsRehydrate: true, + Profile: nil, // as a restored session comes back + } + inv.ctx, inv.cancel = context.WithCancel(context.Background()) + + require.NoError(t, a.rehydrate(inv), "rehydrate") + + require.NotNil(t, captured.Profile, "preflight must receive the live profile, not the nil restored one") + require.Len(t, captured.Profile.Cloud, 1, "cloud sources must reach preflight") + assert.Equal(t, "camunda", captured.Profile.Cloud[0].Alias) + + // inv now carries the live profile so the DTO/status bar can derive cloud chips. + inv.mu.Lock() + defer inv.mu.Unlock() + require.NotNil(t, inv.Profile, "rehydrate should repopulate inv.Profile") + require.Len(t, inv.Profile.Cloud, 1) +} + func TestRehydrate_Success_ClearsNeedsRehydrate(t *testing.T) { dir := t.TempDir() a := &apiHandlers{ diff --git a/internal/server/server.go b/internal/server/server.go index 38ee7d5d..c9789b78 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -81,6 +81,10 @@ type Options struct { // lets each server resolve its own default ($XDG_CACHE_HOME/triagent-mcp/git). GitCacheDir string + // CloudCacheDir is the per-profile dir for the triagent-owned AWS config the + // cloud MCP reads via AWS_CONFIG_FILE (so ~/.aws/config is never written). + CloudCacheDir string + // UserPlaybooksDir holds operator-customised investigation playbooks // the editor writes to, and the strategies MCP layers on top of the // system set. Empty disables the editor's persistence (read-only). @@ -604,6 +608,7 @@ func New(opts Options) (*Server, error) { editorMgr: editorMgr, connections: connMgr, prof: opts.Profile, + cloudProbe: preflight.NewCloudProbe(opts.CloudCacheDir), capabilities: caps, metaCache: cache, mcpHealth: newMCPHealth(), diff --git a/pkg/mcp/cloud/allowlist.go b/pkg/mcp/cloud/allowlist.go new file mode 100644 index 00000000..c0543290 --- /dev/null +++ b/pkg/mcp/cloud/allowlist.go @@ -0,0 +1,187 @@ +package cloud + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "strings" +) + +// defaultCommandsJSON is the parent package's embedded default allowlist. It is +// intentionally empty: provider command sets ship in each provider's own +// default_commands.json (pkg/mcp/cloud/providers/). This anchor lets the +// shared loader compile and gives LoadCommandAllowlist("", …) a valid document. +// +//go:embed default_commands.json +var defaultCommandsJSON []byte + +// Command is one entry in the command allowlist. Path is the normalized +// subcommand path the allowlist matches against (for example "projects list" or +// "compute firewall-rules list"). Description carries the investigative axis the +// command serves (prose only). +type Command struct { + Path string `json:"path"` + Description string `json:"description,omitempty"` +} + +// CommandAllowlist is the decoded allowlist document: the positive set of +// subcommand paths run_cli permits. +type CommandAllowlist struct { + Commands []Command `json:"commands"` +} + +// DenyFloor is the always-on set of subcommands, flags, and argument-value +// prefixes that the config can never re-enable. The base floor lives in this +// package; a Provider contributes provider-specific additions through +// DenyFloorAdditions, mirroring how k8s.LoadAllowlist always drops Secret. +type DenyFloor struct { + Subcommands []string `json:"subcommands,omitempty"` + Flags []string `json:"flags,omitempty"` + ArgPrefixes []string `json:"arg_prefixes,omitempty"` +} + +// denyFloor is the base floor. Config can never re-enable these; they are +// filtered out of any loaded allowlist and rejected in argv validation. The +// floor covers credential-reading and identity/endpoint-redirecting subcommands +// and flags, the wire-tracing flags that echo signed request metadata and auth +// headers to stderr (gcloud --log-http, aws --debug), the project override +// --project (the active target is chosen only via set_active_target, so an argv +// --project can never steer a command away from session_status.active_target), +// plus argument prefixes that read local files or reach the network (local-file +// read and SSRF vectors). +var denyFloor = DenyFloor{ + Subcommands: []string{"secrets", "ssh", "scp", "cp", "sync", "auth", "config"}, + Flags: []string{ + "--impersonate-service-account", "--account", "--profile", + "--endpoint-url", "--cli-input-json", "--cli-input-yaml", "--configuration", + "--flags-file", "--access-token-file", "--log-http", "--debug", "--project", + }, + ArgPrefixes: []string{"file://", "fileb://", "@", "http://", "https://"}, +} + +// mergeDenyFloor combines the base floor with provider additions into one floor. +func mergeDenyFloor(extra DenyFloor) DenyFloor { + return DenyFloor{ + Subcommands: append(append([]string{}, denyFloor.Subcommands...), extra.Subcommands...), + Flags: append(append([]string{}, denyFloor.Flags...), extra.Flags...), + ArgPrefixes: append(append([]string{}, denyFloor.ArgPrefixes...), extra.ArgPrefixes...), + } +} + +// LoadCommandAllowlist returns the command allowlist from path, or the embedded +// default when path is empty, then filters out every command whose subcommand +// path falls under the base deny floor plus the provider's extra additions. A +// too-broad override can never re-enable a floored command — the filter is +// applied identically regardless of input, the LoadAllowlist pattern. +func LoadCommandAllowlist(path string, extra DenyFloor) (*CommandAllowlist, error) { + data := defaultCommandsJSON + if path != "" { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read command allowlist %q: %w", path, err) + } + data = b + } + + var list CommandAllowlist + if err := json.Unmarshal(data, &list); err != nil { + return nil, fmt.Errorf("parse command allowlist: %w", err) + } + + for _, c := range list.Commands { + if c.Path == "" { + return nil, fmt.Errorf("command allowlist entry missing path: %+v", c) + } + } + return filterAllowlist(&list, extra), nil +} + +// filterAllowlist returns a copy of list with every command whose subcommand +// path falls under the base deny floor plus extra dropped. Applied identically +// to a loaded file and to a provider's in-memory default, so neither source can +// advertise a floored command. +func filterAllowlist(list *CommandAllowlist, extra DenyFloor) *CommandAllowlist { + floor := mergeDenyFloor(extra) + out := &CommandAllowlist{Commands: make([]Command, 0, len(list.Commands))} + for _, c := range list.Commands { + if c.Path == "" || floor.blocks(normalizePath(c.Path)) { + continue + } + out.Commands = append(out.Commands, c) + } + return out +} + +// Allows reports whether an allowlisted command path is a token-wise prefix of +// argv's leading positional subcommand path. Flag tokens and their values do +// not participate; only the leading positionals do. Prefix rather than exact +// match lets a describe/get verb chain carry its trailing resource operand +// (`compute instances describe my-vm`): there is no shell, so a trailing token +// is an inert argument to the already-dispatched subcommand. Surplus tokens +// that are shell-control sequences are caught separately in validateArgv. +func (a *CommandAllowlist) Allows(argv []string) bool { + path := subcommandPath(argv) + for _, c := range a.Commands { + if pathHasPrefix(path, normalizePath(c.Path)) { + return true + } + } + return false +} + +// blocks reports whether an allowlist entry's normalized subcommand path is +// prefix-comparable to any floored subcommand, in either direction: +// +// - a floor entry is a token-prefix of path: path sits UNDER a denied path, so +// allowing it runs a floored command directly ("secrets" floors "secrets +// versions access", "compute ssh" floors "compute ssh foo"); +// - path is a token-prefix of a floor entry: path is a parent OF a denied path, +// so allowing it re-admits the floored command through Allows' prefix match +// (a bare "s3" entry would re-admit the floored "s3 cp"). +// +// Both directions drop the entry. An entry that merely shares a leading token +// but diverges deeper ("compute instances list" vs floored "compute ssh") is +// prefix-comparable to neither and stays allowed. +func (d DenyFloor) blocks(path []string) bool { + for _, s := range d.Subcommands { + floor := normalizePath(s) + if pathHasPrefix(path, floor) || pathHasPrefix(floor, path) { + return true + } + } + return false +} + +// subcommandPath returns the leading positional tokens of argv, stopping at the +// first flag (a token beginning with "-"). These tokens form the subcommand +// path the allowlist and deny floor match against. +func subcommandPath(argv []string) []string { + out := make([]string, 0, len(argv)) + for _, tok := range argv { + if strings.HasPrefix(tok, "-") { + break + } + out = append(out, tok) + } + return out +} + +// normalizePath splits a space-separated command path ("compute firewall-rules +// list") into its tokens. +func normalizePath(path string) []string { + return strings.Fields(path) +} + +// pathHasPrefix reports whether prefix is a token-wise prefix of path. +func pathHasPrefix(path, prefix []string) bool { + if len(prefix) == 0 || len(prefix) > len(path) { + return false + } + for i := range prefix { + if path[i] != prefix[i] { + return false + } + } + return true +} diff --git a/pkg/mcp/cloud/allowlist_test.go b/pkg/mcp/cloud/allowlist_test.go new file mode 100644 index 00000000..fcc4c6d7 --- /dev/null +++ b/pkg/mcp/cloud/allowlist_test.go @@ -0,0 +1,120 @@ +package cloud + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTemp(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "allowlist.json") + require.NoError(t, os.WriteFile(p, []byte(body), 0o600)) + return p +} + +func TestLoadCommandAllowlistDropsDenyFloor(t *testing.T) { + t.Parallel() + // JSON that tries to allow a deny-floored subcommand. + path := writeTemp(t, `{"commands":[{"path":"projects list"},{"path":"secrets versions access"}]}`) + al, err := LoadCommandAllowlist(path, DenyFloor{}) + require.NoError(t, err) + assert.False(t, al.Allows([]string{"secrets", "versions", "access"}), + "deny floor must drop secrets access regardless of config") + assert.True(t, al.Allows([]string{"projects", "list"}), "projects list should be allowed") +} + +func TestLoadCommandAllowlistUsesEmbeddedDefaultWhenPathEmpty(t *testing.T) { + t.Parallel() + // The parent package ships no provider commands of its own; an empty path + // yields the empty embedded default, not an error. + al, err := LoadCommandAllowlist("", DenyFloor{}) + require.NoError(t, err) + assert.NotNil(t, al, "expected a non-nil allowlist for the empty default") +} + +func TestLoadCommandAllowlistMergesProviderDenyFloorAdditions(t *testing.T) { + t.Parallel() + path := writeTemp(t, `{"commands":[{"path":"compute instances list"},{"path":"compute ssh foo"}]}`) + extra := DenyFloor{Subcommands: []string{"compute ssh"}} + al, err := LoadCommandAllowlist(path, extra) + require.NoError(t, err) + assert.False(t, al.Allows([]string{"compute", "ssh", "foo"}), + "provider deny-floor addition must drop compute ssh") + assert.True(t, al.Allows([]string{"compute", "instances", "list"}), + "compute instances list should remain allowed") +} + +func TestLoadCommandAllowlistDropsEntryThatIsPrefixOfDenyFloor(t *testing.T) { + t.Parallel() + // An override that allowlists a bare parent of a deny-floored path would, via + // Allows' prefix match, re-admit the floored nested command. Such entries must + // be dropped: "s3" is a token-prefix of the floored "s3 cp", so allowing "s3" + // re-enables "s3 cp". + path := writeTemp(t, `{"commands":[{"path":"s3"},{"path":"compute"},{"path":"storage"}]}`) + extra := DenyFloor{Subcommands: []string{"s3 cp", "compute ssh", "storage cp"}} + al, err := LoadCommandAllowlist(path, extra) + require.NoError(t, err) + assert.False(t, al.Allows([]string{"s3", "cp", "s3://b/k", "-"}), + "a bare 's3' override must not re-admit the floored 's3 cp'") + assert.False(t, al.Allows([]string{"compute", "ssh", "vm"}), + "a bare 'compute' override must not re-admit the floored 'compute ssh'") + assert.False(t, al.Allows([]string{"storage", "cp", "gs://b/o", "-"}), + "a bare 'storage' override must not re-admit the floored 'storage cp'") +} + +func TestLoadCommandAllowlistKeepsDeeperVerbsThatDivergeFromDenyFloor(t *testing.T) { + t.Parallel() + // Entries that share a first token with a floored path but diverge deeper are + // not prefix-comparable to it and must survive: neither path is a prefix of + // the other. + path := writeTemp(t, `{"commands":[ + {"path":"compute instances list"}, + {"path":"s3api list-objects-v2"} + ]}`) + extra := DenyFloor{Subcommands: []string{"s3 cp", "compute ssh"}} + al, err := LoadCommandAllowlist(path, extra) + require.NoError(t, err) + assert.True(t, al.Allows([]string{"compute", "instances", "list"}), + "compute instances list diverges from the floored compute ssh and must survive") + assert.True(t, al.Allows([]string{"s3api", "list-objects-v2", "--bucket", "b"}), + "s3api has a different first token than the floored s3 cp and must survive") +} + +func TestAllowsMatchesVerbChainAsPrefix(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute firewall-rules list"}}} + assert.True(t, al.Allows([]string{"compute", "firewall-rules", "list", "--project", "prod"}), + "argv whose leading tokens match an allowed path should pass") + assert.False(t, al.Allows([]string{"compute", "firewall-rules", "delete"}), + "a different verb under the same group must not be allowed") +} + +func TestAllowsAcceptsResourceOperandAfterVerbChain(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances describe"}}} + // A describe/get command takes a resource operand as a trailing positional; + // the allowlisted verb chain must match as a prefix so the operand rides + // through. There is no shell, so the operand is an inert argument. + assert.True(t, al.Allows([]string{"compute", "instances", "describe", "my-vm", "--project", "prod"}), + "an allowlisted verb chain followed by a resource operand must pass") + assert.True(t, al.Allows([]string{"compute", "instances", "describe", "my-vm", "us-vm-2"}), + "trailing positionals after the verb chain must pass") +} + +func TestAllowsRejectsArgvShorterThanPath(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances describe"}}} + assert.False(t, al.Allows([]string{"compute", "instances"}), + "an argv shorter than the allowlisted path is not a match") +} + +func TestAllowsRejectsDifferentVerbAtPrefixDepth(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances describe"}}} + assert.False(t, al.Allows([]string{"compute", "instances", "delete", "my-vm"}), + "a different verb at the same depth must not match") +} diff --git a/pkg/mcp/cloud/default_commands.json b/pkg/mcp/cloud/default_commands.json new file mode 100644 index 00000000..f2ae3f64 --- /dev/null +++ b/pkg/mcp/cloud/default_commands.json @@ -0,0 +1,3 @@ +{ + "commands": [] +} diff --git a/pkg/mcp/cloud/env.go b/pkg/mcp/cloud/env.go new file mode 100644 index 00000000..2b38a598 --- /dev/null +++ b/pkg/mcp/cloud/env.go @@ -0,0 +1,51 @@ +package cloud + +// Environment variable names the cloud-context MCP subprocess reads. The +// launcher sets these in the subprocess env (never argv); the agent supplies +// argv only and cannot reach them. Provider impersonation env vars +// (CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT, AWS_PROFILE) are contributed by +// the provider packages, not here. +const ( + // EnvProvider selects the concrete provider ("gcp" | "aws"). + EnvProvider = "TRIAGENT_CLOUD_PROVIDER" + // EnvAllowlistPath points at a command-allowlist override file; empty uses + // the provider's embedded default. + EnvAllowlistPath = "TRIAGENT_CLOUD_ALLOWLIST_PATH" + // EnvScope carries the target scope allowlist the launcher froze for this + // session, as JSON the cloud package decodes into ScopeAllowlist. + EnvScope = "TRIAGENT_CLOUD_SCOPE" + // EnvExpectedIdentity carries the identity the launcher pinned for this + // session, uniform across providers: the impersonation target for gcp, the + // expected role ARN for aws. The serve subprocess reads it once at startup and + // threads it into the identity probe; the provider validates the resolved + // identity against it. + EnvExpectedIdentity = "TRIAGENT_CLOUD_EXPECTED_IDENTITY" + // EnvGCPProjects carries the gcp project set as a JSON array of {id, tags} + // objects: the deployment-configured selectable projects and their free-form + // tags. The serve subprocess decodes it into the gcp provider's configured + // targets. Empty means unconstrained — the provider lists projects live + // instead. gcp-only. + EnvGCPProjects = "TRIAGENT_CLOUD_GCP_PROJECTS" + // EnvAWSAccounts carries the aws account set as a JSON array of + // {account_id, role_arn, tags} objects. The serve subprocess decodes it and + // builds the aws provider's configured targets and generated assume-role + // profiles. A single-account source is a one-entry array. gcp leaves it empty. + EnvAWSAccounts = "TRIAGENT_CLOUD_AWS_ACCOUNTS" + // EnvAWSSourceProfile carries the operator's SSO base profile the generated + // multi-account assume-role profiles layer their role over. aws-only. + EnvAWSSourceProfile = "TRIAGENT_CLOUD_AWS_SOURCE_PROFILE" + // EnvAWSAlias carries the cloud source's alias, the namespace for the + // generated assume-role profile names (triagent-cloud--). + // The launcher-side probe and the serve subprocess both build the provider + // with it, so they name and generate the same profiles. aws-only. + EnvAWSAlias = "TRIAGENT_CLOUD_AWS_ALIAS" + // EnvAWSConfigFile is the standard aws CLI config-file selector. The launcher + // sets it to the triagent-owned per-profile config (under CloudCacheDir): the + // aws CLI reads it, and the provider generates into it. aws-only. + EnvAWSConfigFile = "AWS_CONFIG_FILE" + // EnvAWSSourceConfig is the operator's own aws config the provider copies into + // EnvAWSConfigFile so source_profile resolves. Distinct from EnvAWSConfigFile + // (the generated target) because the subprocess sees the latter as + // AWS_CONFIG_FILE and cannot infer the operator's original. aws-only. + EnvAWSSourceConfig = "TRIAGENT_CLOUD_AWS_SOURCE_CONFIG" +) diff --git a/pkg/mcp/cloud/fake_test.go b/pkg/mcp/cloud/fake_test.go new file mode 100644 index 00000000..b5a35460 --- /dev/null +++ b/pkg/mcp/cloud/fake_test.go @@ -0,0 +1,62 @@ +package cloud + +import "context" + +// fakeProvider is the in-package test double for the Provider interface. +// Providers (gcp, aws) implement the same contract in their own subpackages; +// this fake exercises the parent package's harness, tools, and probe without +// shelling any real cloud CLI. +type fakeProvider struct { + name string + binary string + allowlist *CommandAllowlist + denyFloor DenyFloor + inventory Inventory + identity IdentityStatus + identityErr error + envPassthrough []string + targets []Target + expectedFor map[string]string +} + +func (f *fakeProvider) Name() string { + if f.name == "" { + return "fake" + } + return f.name +} + +func (f *fakeProvider) Binary() string { + if f.binary == "" { + return "/bin/true" + } + return f.binary +} + +func (f *fakeProvider) DefaultAllowlist() *CommandAllowlist { + if f.allowlist == nil { + return &CommandAllowlist{} + } + return f.allowlist +} + +func (f *fakeProvider) DenyFloorAdditions() DenyFloor { return f.denyFloor } + +func (f *fakeProvider) EnvPassthrough() []string { return f.envPassthrough } + +func (f *fakeProvider) Inventory(context.Context, RunFunc) (Inventory, error) { + return f.inventory, nil +} + +func (f *fakeProvider) Identity(context.Context, RunFunc, string) (IdentityStatus, error) { + return f.identity, f.identityErr +} + +func (f *fakeProvider) ConfiguredTargets() []Target { return f.targets } + +func (f *fakeProvider) ActiveTargetEnv(id string) []string { return []string{"FAKE_TARGET=" + id} } + +func (f *fakeProvider) ExpectedIdentity(id string) (string, bool) { + exp, ok := f.expectedFor[id] + return exp, ok +} diff --git a/pkg/mcp/cloud/harness.go b/pkg/mcp/cloud/harness.go new file mode 100644 index 00000000..8c2b11d3 --- /dev/null +++ b/pkg/mcp/cloud/harness.go @@ -0,0 +1,80 @@ +package cloud + +import ( + "context" + "errors" + "os/exec" +) + +// defaultOutputLimit caps run_cli stdout so a raw provider response cannot blow +// the agent's context budget. Output beyond it is dropped and flagged. +const defaultOutputLimit = 64 * 1024 + +// limitedWriter retains at most limit bytes of everything written to it and +// records whether any write pushed it past that cap. It never grows past limit, +// so a command emitting an arbitrarily large response cannot consume unbounded +// memory: bytes past the cap are counted for the overflow flag and discarded. +type limitedWriter struct { + buf []byte + limit int + overflow bool +} + +// Write retains up to the remaining capacity in the buffer and discards the +// rest, flagging overflow whenever a write carries more bytes than the buffer +// can still hold. It always reports the full length written so the child +// process is never blocked on a short write. +func (w *limitedWriter) Write(p []byte) (int, error) { + room := w.limit - len(w.buf) + if len(p) > room { + w.overflow = true + } + if room > 0 { + take := len(p) + if take > room { + take = room + } + w.buf = append(w.buf, p[:take]...) + } + return len(p), nil +} + +// execCLI runs binPath with argv via execve — no shell, ever. The argv tokens +// reach the binary as literal arguments, so shell metacharacters are inert. The +// subprocess runs with exactly the supplied env (never the parent environment, +// so a poisoned PATH cannot redirect the binary and ambient secrets do not +// leak), closed stdin (no interactive prompt), and stdout/stderr captured +// through bounded writers that retain at most limit bytes each — the cap is +// effective during the run, so a command emitting a very large response can +// never buffer it all in memory. A non-zero exit is a normal result carried in +// ExitCode, not a Go error; a Go error means the process could not be run at +// all. Stderr — where gcloud/aws write their error context — is captured +// alongside stdout and capped at the same limit, so a non-zero exit carries an +// explanation instead of an empty result. +func execCLI(ctx context.Context, binPath string, argv []string, env []string, limit int) (CLIResult, error) { + cmd := exec.CommandContext(ctx, binPath, argv...) + cmd.Env = env + cmd.Stdin = nil + + stdout := &limitedWriter{limit: limit} + stderr := &limitedWriter{limit: limit} + cmd.Stdout = stdout + cmd.Stderr = stderr + err := cmd.Run() + + res := CLIResult{ + Stdout: string(stdout.buf), + Stderr: string(stderr.buf), + Truncated: stdout.overflow || stderr.overflow, + } + + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + return res, nil + } + return CLIResult{}, err + } + return res, nil +} diff --git a/pkg/mcp/cloud/harness_security_test.go b/pkg/mcp/cloud/harness_security_test.go new file mode 100644 index 00000000..13a6a98e --- /dev/null +++ b/pkg/mcp/cloud/harness_security_test.go @@ -0,0 +1,62 @@ +package cloud + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestExecCLINeverUsesShell is the source-level half of the no-shell guarantee: +// the exec core must never construct a shell command. There is no "-c" string, +// no "sh -c", no "bash -c" anywhere in harness.go. +func TestExecCLINeverUsesShell(t *testing.T) { + t.Parallel() + src, err := os.ReadFile("harness.go") + require.NoError(t, err) + for _, banned := range []string{`"-c"`, "sh -c", "bash -c", `"sh"`, `"bash"`} { + assert.False(t, bytes.Contains(src, []byte(banned)), + "harness.go must never construct a shell command; found %q", banned) + } +} + +// TestExecCLIMetacharactersAreInert is the behavioural half: shell +// metacharacters handed to a binary as argv tokens are literal arguments, never +// interpreted. Running /bin/echo with metacharacter tokens prints them verbatim +// and spawns no second process. +func TestExecCLIMetacharactersAreInert(t *testing.T) { + t.Parallel() + argv := []string{";", "echo", "pwned", "|", "$(whoami)", "&&", "`id`"} + r, err := execCLI(context.Background(), "/bin/echo", argv, nil, 4096) + require.NoError(t, err) + got := strings.TrimRight(r.Stdout, "\n") + want := strings.Join(argv, " ") + require.Equal(t, want, got, "metacharacters were not inert") + assert.False(t, strings.Contains(r.Stdout, "pwned\n") && got != want, + "a second process appears to have run") +} + +// TestExecCLITruncates caps output at the byte limit and flags truncation. +func TestExecCLITruncates(t *testing.T) { + t.Parallel() + r, err := execCLI(context.Background(), "/bin/echo", []string{strings.Repeat("x", 100)}, nil, 10) + require.NoError(t, err) + assert.True(t, r.Truncated, "expected Truncated, got %+v", r) + assert.LessOrEqual(t, len(r.Stdout), 10, "output exceeded limit") +} + +// TestExecCLIMinimalEnv confirms the subprocess runs with the caller's explicit +// env, not the parent process environment, so a poisoned PATH cannot redirect +// the resolved binary and ambient secrets do not leak in. +func TestExecCLIMinimalEnv(t *testing.T) { + t.Setenv("TRIAGENT_CLOUD_HARNESS_LEAK_CANARY", "should-not-appear") + r, err := execCLI(context.Background(), "/usr/bin/env", nil, []string{"FOO=bar"}, 4096) + require.NoError(t, err) + assert.NotContains(t, r.Stdout, "TRIAGENT_CLOUD_HARNESS_LEAK_CANARY", + "subprocess inherited the parent environment; env must be explicit") + assert.Contains(t, r.Stdout, "FOO=bar", "explicit env not applied") +} diff --git a/pkg/mcp/cloud/harness_test.go b/pkg/mcp/cloud/harness_test.go new file mode 100644 index 00000000..79a0f5b5 --- /dev/null +++ b/pkg/mcp/cloud/harness_test.go @@ -0,0 +1,59 @@ +package cloud + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestExecCLIExitCode surfaces the child's exit code without treating a +// non-zero exit as a Go error: a CLI that exits 1 on "not found" is a normal +// result the agent should see, not a harness failure. +func TestExecCLIExitCode(t *testing.T) { + t.Parallel() + r, err := execCLI(context.Background(), "/bin/false", nil, nil, 4096) + require.NoError(t, err, "non-zero exit should not be a Go error") + assert.Equal(t, 1, r.ExitCode) +} + +// TestExecCLICapturesStderr proves a non-zero exit carries the child's stderr, +// the context gcloud/aws write errors to. Without it run_cli would surface an +// empty stdout and no explanation for the failure. +func TestExecCLICapturesStderr(t *testing.T) { + t.Parallel() + // /bin/sh here is only the test fixture producing a stderr write + nonzero + // exit; the harness itself never shells (see harness_security_test.go). + r, err := execCLI(context.Background(), "/bin/sh", + []string{"-c", "echo boom 1>&2; exit 3"}, nil, 4096) + require.NoError(t, err, "non-zero exit should not be a Go error") + assert.Equal(t, 3, r.ExitCode) + assert.Contains(t, r.Stderr, "boom", "stderr must be captured") +} + +// TestExecCLITruncatesStderr caps stderr at the same limit as stdout so a +// noisy provider error cannot blow the context budget. +func TestExecCLITruncatesStderr(t *testing.T) { + t.Parallel() + r, err := execCLI(context.Background(), "/bin/sh", + []string{"-c", "printf '%0.sx' $(seq 1 100) 1>&2; exit 1"}, nil, 10) + require.NoError(t, err) + assert.LessOrEqual(t, len(r.Stderr), 10, "stderr exceeded limit") +} + +// TestExecCLICapsLargeOutputWithoutBuffering drives a payload orders of +// magnitude past the limit through a shell-free command (head reading 8MB from +// /dev/zero) and asserts the captured stdout is capped at the limit with +// Truncated set, so a command emitting a very large response cannot retain +// unbounded bytes in memory. The cap is effective during the run, not a +// post-hoc slice of a fully buffered output. +func TestExecCLICapsLargeOutputWithoutBuffering(t *testing.T) { + t.Parallel() + const limit = 1024 + r, err := execCLI(context.Background(), "/usr/bin/head", + []string{"-c", "8388608", "/dev/zero"}, nil, limit) + require.NoError(t, err) + assert.True(t, r.Truncated, "an output far larger than limit must be flagged truncated") + assert.LessOrEqual(t, len(r.Stdout), limit, "captured stdout must be capped at limit, not the full 8MB payload") +} diff --git a/pkg/mcp/cloud/probe.go b/pkg/mcp/cloud/probe.go new file mode 100644 index 00000000..38f65a85 --- /dev/null +++ b/pkg/mcp/cloud/probe.go @@ -0,0 +1,51 @@ +package cloud + +import ( + "context" + "fmt" +) + +// Probe runs the read-only whoami for one provider: which pinned identity is +// active and whether it is valid. It is the single probe the launcher's +// connections panel, the session preflight gate, and the session_status tool +// all call, so those surfaces can never disagree. +// +// expected is the identity the launcher pinned for this session, threaded +// explicitly so the probe validates against it without reading process-global +// env; env is the exact subprocess environment the whoami exec runs under, +// passed in by the caller rather than read from os.Environ here. +// +// Probe never returns a Go error for an unreachable or invalid identity — that +// is a degrade, reported through IdentityStatus.Valid and Hint, so a stale cloud +// credential surfaces visibly instead of failing the caller. A Go error is +// reserved for a caller contract violation (a nil provider). +func Probe(ctx context.Context, p Provider, expected string, env []string) (IdentityStatus, error) { + if p == nil { + return IdentityStatus{}, fmt.Errorf("cloud: Probe requires a provider") + } + run := func(ctx context.Context, argv []string) (CLIResult, error) { + return execCLI(ctx, p.Binary(), argv, env, defaultOutputLimit) + } + + st, err := p.Identity(ctx, run, expected) + if err != nil { + return IdentityStatus{ + Provider: p.Name(), + AssumedIdentity: expected, + Valid: false, + Hint: err.Error(), + }, nil + } + if st.Provider == "" { + st.Provider = p.Name() + } + if st.AssumedIdentity == "" { + // A whoami that resolved no identity is not a valid session, whatever + // the provider reported. Report the pinned identity so the degraded + // session names which credential the operator must fix instead of an + // empty one. + st.Valid = false + st.AssumedIdentity = expected + } + return st, nil +} diff --git a/pkg/mcp/cloud/probe_test.go b/pkg/mcp/cloud/probe_test.go new file mode 100644 index 00000000..348de8f3 --- /dev/null +++ b/pkg/mcp/cloud/probe_test.go @@ -0,0 +1,144 @@ +package cloud + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// envProbeProvider drives the probe through a real subprocess: Binary is +// /usr/bin/env, which with no argv prints the environment it was handed. Its +// Identity runs that subprocess and reports the raw env back through +// IdentityStatus.AssumedIdentity, so a test can assert exactly which variables +// crossed the process boundary. +type envProbeProvider struct { + name string + envPassthrough []string +} + +func (p *envProbeProvider) Name() string { return p.name } +func (p *envProbeProvider) Binary() string { return "/usr/bin/env" } +func (p *envProbeProvider) DefaultAllowlist() *CommandAllowlist { return &CommandAllowlist{} } +func (p *envProbeProvider) DenyFloorAdditions() DenyFloor { return DenyFloor{} } +func (p *envProbeProvider) EnvPassthrough() []string { return p.envPassthrough } +func (p *envProbeProvider) Inventory(context.Context, RunFunc) (Inventory, error) { + return Inventory{}, nil +} + +func (p *envProbeProvider) ConfiguredTargets() []Target { return nil } +func (p *envProbeProvider) ActiveTargetEnv(id string) []string { return []string{"FAKE_TARGET=" + id} } +func (p *envProbeProvider) ExpectedIdentity(string) (string, bool) { return "", false } +func (p *envProbeProvider) Identity(ctx context.Context, run RunFunc, _ string) (IdentityStatus, error) { + res, err := run(ctx, nil) + if err != nil { + return IdentityStatus{}, err + } + return IdentityStatus{ + Provider: p.name, + AssumedIdentity: res.Stdout, + Valid: true, + }, nil +} + +func TestProbeReturnsProviderIdentity(t *testing.T) { + t.Parallel() + p := &fakeProvider{ + name: "gcp", + identity: IdentityStatus{ + Provider: "gcp", + AssumedIdentity: "ro-sa@proj.iam.gserviceaccount.com", + Valid: true, + }, + } + st, err := Probe(context.Background(), p, "", nil) + require.NoError(t, err) + assert.True(t, st.Valid) + assert.Equal(t, "ro-sa@proj.iam.gserviceaccount.com", st.AssumedIdentity) +} + +func TestProbeErrorsOnNilProvider(t *testing.T) { + t.Parallel() + _, err := Probe(context.Background(), nil, "", nil) + require.Error(t, err, "a nil provider is a caller contract violation, not a degrade") +} + +func TestProbeSurfacesProviderErrorAsInvalid(t *testing.T) { + t.Parallel() + p := &fakeProvider{name: "aws", identityErr: errors.New("token expired")} + st, err := Probe(context.Background(), p, "", nil) + require.NoError(t, err, "Probe should degrade, not error") + assert.False(t, st.Valid, "expected Valid=false when the provider errors") + assert.Equal(t, "aws", st.Provider, "expected provider name carried through") + assert.NotEmpty(t, st.Hint, "expected the provider error surfaced as a hint") +} + +// TestProbeExecsWithExactlyTheGivenEnv proves the probe execs the whoami +// subprocess under exactly the env the caller passed, with no read of +// os.Environ inside Probe: a parent canary set in the process env must not +// cross the boundary, while a var present only in the passed env survives. +func TestProbeExecsWithExactlyTheGivenEnv(t *testing.T) { + t.Setenv("TRIAGENT_CLOUD_LEAK_CANARY", "should-not-appear") + p := &envProbeProvider{name: "gcp"} + + env := []string{ + "PATH=" + os.Getenv("PATH"), + "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=ro-sa@proj.iam.gserviceaccount.com", + } + st, err := Probe(context.Background(), p, "", env) + require.NoError(t, err) + + seen := st.AssumedIdentity + assert.NotContains(t, seen, "TRIAGENT_CLOUD_LEAK_CANARY", + "a var present only in the process env, not the passed env, must not reach the subprocess") + assert.Contains(t, seen, "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=ro-sa@proj.iam.gserviceaccount.com", + "the passed env must reach the probe subprocess") + for _, line := range strings.Split(seen, "\n") { + if line == "" { + continue + } + name, _, _ := strings.Cut(line, "=") + assert.Contains(t, []string{"PATH", "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"}, name, + "only the names in the passed env may cross the boundary") + } +} + +func TestProbeInvalidWhenIdentityEmpty(t *testing.T) { + t.Parallel() + p := &fakeProvider{name: "gcp", identity: IdentityStatus{Provider: "gcp", Valid: true}} + st, err := Probe(context.Background(), p, "", nil) + require.NoError(t, err) + assert.False(t, st.Valid, "an empty resolved identity must not be reported valid") +} + +// TestProbeDegradedReportsPinnedIdentity proves a degraded probe still names +// WHICH pinned identity is degraded: when the provider errors and resolves no +// identity, Probe falls back to the expected identity the caller pinned, so +// session_status stays actionable instead of showing an empty identity. +func TestProbeDegradedReportsPinnedIdentity(t *testing.T) { + t.Parallel() + const pinned = "ro-sa@proj.iam.gserviceaccount.com" + p := &fakeProvider{name: "gcp", identityErr: errors.New("token expired")} + st, err := Probe(context.Background(), p, pinned, nil) + require.NoError(t, err, "Probe should degrade, not error") + assert.False(t, st.Valid) + assert.Equal(t, pinned, st.AssumedIdentity, + "a degraded probe must report the pinned identity so the operator knows what to fix") +} + +// TestProbeFallsBackToExpectedWhenProviderOmitsIdentity covers the valid path: +// a provider that resolves to valid but reports no identity (an unusual but +// possible projection gap) still shows the pinned identity rather than empty. +func TestProbeFallsBackToExpectedWhenProviderOmitsIdentity(t *testing.T) { + t.Parallel() + const pinned = "arn:aws:iam::111122223333:role/triage-ro" + p := &fakeProvider{name: "aws", identity: IdentityStatus{Provider: "aws", Valid: true}} + st, err := Probe(context.Background(), p, pinned, nil) + require.NoError(t, err) + assert.Equal(t, pinned, st.AssumedIdentity, + "an empty resolved identity must fall back to the pinned identity") +} diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go new file mode 100644 index 00000000..649c3c36 --- /dev/null +++ b/pkg/mcp/cloud/provider.go @@ -0,0 +1,114 @@ +// Package cloud implements the read-only cloud-context MCP server the +// triagent-mcp binary exposes to Claude. One package serves both GCP and AWS: +// the cloud-specific behaviour sits behind the Provider interface, selected at +// launch by --provider and plugged in from pkg/mcp/cloud/providers/. +// +// The server is read-only by construction. run_cli never touches a shell, every +// invocation is validated against a positive command allowlist plus a hardcoded +// deny floor the config can never re-enable, and the cloud identity is pinned by +// the deployment through harness-controlled env the agent cannot reach. +package cloud + +import "context" + +// Provider is the cloud-specific seam every tool calls through. Selecting +// --provider chooses the concrete gcp or aws implementation, injected behind +// this interface (the teleport DI pattern). Implementations live in +// pkg/mcp/cloud/providers/, never in this package. +type Provider interface { + // Name reports the provider identifier ("gcp" | "aws"). + Name() string + // Binary is the resolved absolute path to the provider CLI (gcloud/aws). + Binary() string + // DefaultAllowlist is the provider's embedded default command allowlist. + DefaultAllowlist() *CommandAllowlist + // DenyFloorAdditions contributes provider-specific subcommands and flags to + // the always-on deny floor. The base floor lives in this package; providers + // only add to it, never relax it. + DenyFloorAdditions() DenyFloor + // EnvPassthrough lists the environment variable NAMES this provider's CLI + // needs forwarded from the launcher-controlled process env to the subprocess + // (base credentials, the pinned-identity impersonation target, config dirs). + // The harness forwards only these plus a minimal base set; every other parent + // env var is dropped, so ambient launcher secrets never reach the CLI. + EnvPassthrough() []string + // Inventory projects the provider's accessible scopes (projects for gcp, + // accounts for aws). It execs only through run, never directly. + Inventory(ctx context.Context, run RunFunc) (Inventory, error) + // Identity is the read-only whoami: which pinned identity is active and + // whether it is valid. expected is the identity the launcher pinned for this + // session (the impersonation target for gcp, the expected role ARN for aws, + // empty when none is pinned); the provider validates the resolved identity + // against it. It execs only through run, never directly. + Identity(ctx context.Context, run RunFunc, expected string) (IdentityStatus, error) + // ConfiguredTargets is the deployment-configured selectable set the provider + // itself knows (aws: its accounts list). Empty when the set comes from the + // server's scope/inventory instead (gcp). + ConfiguredTargets() []Target + // ActiveTargetEnv returns the env var(s) that pin the CLI to targetID for the + // next invocation: gcp CLOUDSDK_CORE_PROJECT, aws AWS_PROFILE. The agent never + // supplies these; the server sets them per-exec. + ActiveTargetEnv(targetID string) []string + // ExpectedIdentity returns the identity the active target must resolve to when + // the provider pins it per-target (aws: the account's read-only role ARN), so + // session_status validates the active target against the right identity on + // switch. ok is false when the provider's identity is uniform across targets + // (gcp: one impersonated service account spans its projects), leaving the + // server to validate against the session's pinned identity instead. + ExpectedIdentity(targetID string) (string, bool) +} + +// Target is one selectable project (gcp) or account (aws) the agent may make +// active via set_active_target. Tags are the deployment's free-form labels for +// the target (e.g. "prod", "payments"), surfaced so the agent can judge which +// target an investigation belongs to. +type Target struct { + ID string `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags,omitempty"` +} + +// RunFunc is the harness exec core, injected into providers so they never exec +// directly. It carries the no-shell guarantee: argv tokens reach the provider +// binary via execve, never a shell. +type RunFunc func(ctx context.Context, argv []string) (CLIResult, error) + +// Inventory is the projected list of accessible scopes the agent uses to orient. +type Inventory struct { + Scopes []Scope `json:"scopes"` +} + +// Scope is one project (gcp) or account (aws) the agent can reach, as surfaced +// by list_inventory. Tags are the deployment's free-form labels for it (e.g. +// "prod", "payments"), so the agent can judge which target is relevant. +type Scope struct { + ID string `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags,omitempty"` +} + +// IdentityStatus is the single struct the identity probe returns. The +// connections array, the session_status tool, and the preflight gate all render +// from it, so they cannot disagree. JSON tags are a downstream contract. +type IdentityStatus struct { + Provider string `json:"provider"` + AssumedIdentity string `json:"assumed_identity"` + Valid bool `json:"valid"` + Hint string `json:"hint,omitempty"` + // ActiveTarget is the project (gcp) or account (aws) run_cli currently runs + // against. The probe leaves it empty; the server fills it from its + // active-target state so session_status reports the identity and the target + // together. + ActiveTarget string `json:"active_target,omitempty"` +} + +// CLIResult is the result of one run_cli invocation. It carries the provider +// CLI's raw stdout (and stderr), each capped at the output limit with Truncated +// set when the output exceeded it. The bytes are not otherwise shaped or +// redacted; callers must not assume any projection beyond truncation. +type CLIResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr,omitempty"` + Truncated bool `json:"truncated"` + ExitCode int `json:"exit_code"` +} diff --git a/pkg/mcp/cloud/providers/aws/default_commands.json b/pkg/mcp/cloud/providers/aws/default_commands.json new file mode 100644 index 00000000..f6fd4b8f --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/default_commands.json @@ -0,0 +1,46 @@ +{ + "commands": [ + { "path": "sts get-caller-identity", "description": "identity: resolve the active caller ARN/account (whoami; inventory single-account fallback)" }, + + { "path": "organizations list-accounts", "description": "inventory: list the accounts the pinned identity can see across the organization" }, + { "path": "organizations describe-organization", "description": "inventory: describe the organization the caller belongs to" }, + + { "path": "ec2 describe-instances", "description": "inventory: list EC2 instances and their state/placement" }, + { "path": "ec2 describe-vpcs", "description": "reachability: list VPCs the workload network sits in" }, + { "path": "ec2 describe-subnets", "description": "reachability: list subnets and their AZ/route association" }, + { "path": "ec2 describe-security-groups", "description": "reachability: inspect security-group ingress/egress rules" }, + { "path": "ec2 describe-network-interfaces", "description": "reachability: map ENIs to instances/security groups" }, + { "path": "ec2 describe-route-tables", "description": "reachability: inspect route tables and their associations" }, + { "path": "ec2 describe-nat-gateways", "description": "reachability: locate NAT gateways for egress paths" }, + { "path": "ec2 describe-internet-gateways", "description": "reachability: locate internet gateways for ingress/egress" }, + { "path": "ec2 describe-network-acls", "description": "reachability: inspect subnet-level network ACL rules" }, + { "path": "ec2 describe-vpc-peering-connections", "description": "reachability: inspect cross-VPC peering paths" }, + { "path": "ec2 describe-vpc-endpoints", "description": "reachability: inspect private-service VPC endpoints" }, + + { "path": "iam get-role", "description": "permissions: read a single IAM role and its trust policy" }, + { "path": "iam list-roles", "description": "permissions: enumerate IAM roles in the account" }, + { "path": "iam list-attached-role-policies", "description": "permissions: list managed policies attached to a role" }, + { "path": "iam list-role-policies", "description": "permissions: list inline policy names on a role" }, + { "path": "iam get-role-policy", "description": "permissions: read an inline role policy document" }, + { "path": "iam get-policy", "description": "permissions: read a managed policy's metadata" }, + { "path": "iam get-policy-version", "description": "permissions: read a managed policy version document" }, + { "path": "iam list-policies", "description": "permissions: enumerate managed policies" }, + { "path": "iam simulate-principal-policy", "description": "permissions: simulate whether a principal is allowed an action (read-only evaluation)" }, + + { "path": "eks describe-cluster", "description": "cluster: read EKS cluster networking and config" }, + { "path": "eks list-clusters", "description": "cluster: enumerate EKS clusters in the account/region" }, + { "path": "eks describe-nodegroup", "description": "cluster: read an EKS managed nodegroup's config" }, + { "path": "eks list-nodegroups", "description": "cluster: enumerate EKS nodegroups for a cluster" }, + { "path": "eks list-fargate-profiles", "description": "cluster: enumerate EKS Fargate profiles for a cluster" }, + { "path": "eks describe-fargate-profile", "description": "cluster: read an EKS Fargate profile's config" }, + + { "path": "logs describe-log-groups", "description": "logs: enumerate CloudWatch log groups" }, + { "path": "logs describe-log-streams", "description": "logs: enumerate log streams within a group" }, + { "path": "logs filter-log-events", "description": "logs: read CloudWatch log events filtered by pattern/time" }, + { "path": "logs get-log-events", "description": "logs: read raw CloudWatch log events from a stream" }, + + { "path": "cloudtrail lookup-events", "description": "audit: read recent management-event history from CloudTrail" }, + { "path": "cloudtrail describe-trails", "description": "audit: enumerate configured CloudTrail trails" }, + { "path": "cloudtrail get-trail-status", "description": "audit: read whether a CloudTrail trail is actively logging" } + ] +} diff --git a/pkg/mcp/cloud/providers/aws/flock_unix.go b/pkg/mcp/cloud/providers/aws/flock_unix.go new file mode 100644 index 00000000..416d64d5 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/flock_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package aws + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// lockExclusive takes an exclusive advisory lock on f, blocking until it is +// granted. Paired with unlockFile, it serializes the managed-profile +// read-modify-write across processes (the launcher probe and each serve +// subprocess all generate into the same ~/.aws/config). +func lockExclusive(f *os.File) error { return unix.Flock(int(f.Fd()), unix.LOCK_EX) } + +// unlockFile releases the advisory lock held on f. +func unlockFile(f *os.File) error { return unix.Flock(int(f.Fd()), unix.LOCK_UN) } diff --git a/pkg/mcp/cloud/providers/aws/flock_windows.go b/pkg/mcp/cloud/providers/aws/flock_windows.go new file mode 100644 index 00000000..87b30e1b --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/flock_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package aws + +import "os" + +// Windows has no flock. Same-process managed-profile writes are serialized by +// the package mutex; cross-process AWS-config generation is not a supported +// concurrency mode on Windows, so these are no-ops that keep the write path +// compiling and running. +func lockExclusive(*os.File) error { return nil } + +func unlockFile(*os.File) error { return nil } diff --git a/pkg/mcp/cloud/providers/aws/identity.go b/pkg/mcp/cloud/providers/aws/identity.go new file mode 100644 index 00000000..9bfd0be8 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/identity.go @@ -0,0 +1,135 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +// callerIdentity is the projection of `aws sts get-caller-identity --output +// json`. Only the fields the probe and inventory fallback use are decoded. +type callerIdentity struct { + UserID string `json:"UserId"` + Account string `json:"Account"` + Arn string `json:"Arn"` +} + +// Identity is the read-only whoami over the assumed role. It runs `aws sts +// get-caller-identity` through the injected run core (unvalidated under Probe; +// the command is also allowlisted so it works under the validated core), parses +// the caller ARN, and reports whether the pinned assume-role identity is active. +// +// Validity has two modes. With expected set to a role ARN, the caller's +// underlying role must match it exactly, and the displayed identity is that +// canonical role ARN (not the per-session STS assumed-role ARN, whose session +// segment changes each run). Without it, the structural check applies: the +// caller must be an assumed-role ARN, which proves the AWS_PROFILE pin took +// effect — a plain user/root ARN means base credentials leaked through +// unimpersonated, so the session is not valid — and the resolved caller ARN is +// displayed as-is. +func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc, expected string) (cloud.IdentityStatus, error) { + res, err := run(ctx, []string{"sts", "get-caller-identity", "--output", "json"}) + if err != nil { + return cloud.IdentityStatus{Provider: "aws", Valid: false, Hint: err.Error()}, nil + } + if res.ExitCode != 0 { + return cloud.IdentityStatus{ + Provider: "aws", + Valid: false, + Hint: "aws sts get-caller-identity failed; re-authenticate your base credentials (e.g. aws sso login)", + }, nil + } + + var caller callerIdentity + if err := json.Unmarshal([]byte(res.Stdout), &caller); err != nil { + return cloud.IdentityStatus{ + Provider: "aws", + Valid: false, + Hint: fmt.Sprintf("parse caller identity: %v", err), + }, nil + } + + st := cloud.IdentityStatus{Provider: "aws", AssumedIdentity: caller.Arn} + st.Valid, st.Hint = evaluateIdentity(caller.Arn, expected) + // When a pinned role ARN matched, display it rather than the per-session + // STS assumed-role ARN: the STS ARN carries a fresh session segment each + // run, so showing it would make the stable configured identity look like it + // keeps changing across /api/connections and session_status. + if st.Valid && expected != "" { + st.AssumedIdentity = expected + } + return st, nil +} + +// evaluateIdentity decides whether a resolved caller ARN represents the pinned +// read-only assume-role identity. It returns validity plus a hint explaining a +// degrade. +func evaluateIdentity(arn, expectedRoleARN string) (bool, string) { + role, ok := assumedRoleARN(arn) + if !ok { + return false, "active identity is not an assumed role; the AWS_PROFILE assume-role pin did not take effect — re-authenticate your base credentials (e.g. aws sso login)" + } + if expectedRoleARN != "" && role != expectedRoleARN { + return false, fmt.Sprintf("assumed role %q does not match the pinned read-only role %q", role, expectedRoleARN) + } + return true, "" +} + +// assumedRoleARN reports whether arn is an STS assumed-role ARN and, if so, +// returns the canonical IAM role ARN behind it. An assumed-role ARN has the +// shape arn::sts:::assumed-role//, +// across the aws, aws-us-gov, and aws-cn partitions. The role keeps any IAM +// path: the role-path-and-name is everything between "assumed-role/" and the +// final "/" segment, so a path-prefixed role like +// assumed-role/team/sub/Role/session resolves to role/team/sub/Role. The IAM +// role it stands for is arn::iam:::role/. +func assumedRoleARN(arn string) (string, bool) { + const stsInfix = ":sts::" + const marker = ":assumed-role/" + partition, ok := arnPartition(arn) + if !ok { + return "", false + } + if !strings.HasPrefix(arn, "arn:"+partition+stsInfix) { + return "", false + } + idx := strings.Index(arn, marker) + if idx < 0 { + return "", false + } + account := arn[len("arn:"+partition+stsInfix):idx] + rest := arn[idx+len(marker):] + // The session name is the final slash-delimited segment; the role path and + // name is everything before it. + rolePath, _, found := lastCut(rest, "/") + if !found || rolePath == "" || account == "" { + return "", false + } + return fmt.Sprintf("arn:%s:iam::%s:role/%s", partition, account, rolePath), true +} + +// arnPartition returns the partition segment of an ARN (the field between the +// first two colons of "arn::..."). +func arnPartition(arn string) (string, bool) { + rest, ok := strings.CutPrefix(arn, "arn:") + if !ok { + return "", false + } + partition, _, found := strings.Cut(rest, ":") + if !found || partition == "" { + return "", false + } + return partition, true +} + +// lastCut splits s around the last instance of sep, returning the text before +// and after it. found reports whether sep appears in s. +func lastCut(s, sep string) (before, after string, found bool) { + if i := strings.LastIndex(s, sep); i >= 0 { + return s[:i], s[i+len(sep):], true + } + return s, "", false +} diff --git a/pkg/mcp/cloud/providers/aws/identity_test.go b/pkg/mcp/cloud/providers/aws/identity_test.go new file mode 100644 index 00000000..d20e35f8 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/identity_test.go @@ -0,0 +1,160 @@ +package aws + +import ( + "context" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const callerIdentityAssumedRole = `{ + "UserId": "AROAEXAMPLE:triagent-session", + "Account": "111122223333", + "Arn": "arn:aws:sts::111122223333:assumed-role/triagent-readonly/triagent-session" +}` + +const callerIdentityPlainUser = `{ + "UserId": "AIDAEXAMPLE", + "Account": "111122223333", + "Arn": "arn:aws:iam::111122223333:user/operator" +}` + +func TestIdentityBuildsCallerIdentityArgv(t *testing.T) { + f := &fakeRun{results: map[string]cloud.CLIResult{ + "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, + }} + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + _, err = p.Identity(context.Background(), f.run, "") + require.NoError(t, err) + + require.Len(t, f.calls, 1) + assert.Equal(t, []string{"sts", "get-caller-identity", "--output", "json"}, f.calls[0]) +} + +func TestIdentityValidWhenAssumedRole(t *testing.T) { + f := &fakeRun{results: map[string]cloud.CLIResult{ + "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, + }} + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + st, err := p.Identity(context.Background(), f.run, "") + require.NoError(t, err) + + assert.Equal(t, "aws", st.Provider) + assert.Equal(t, "arn:aws:sts::111122223333:assumed-role/triagent-readonly/triagent-session", st.AssumedIdentity) + assert.True(t, st.Valid, "an assumed-role ARN proves the pinned profile took effect") +} + +func TestIdentityInvalidWhenNotAssumedRole(t *testing.T) { + f := &fakeRun{results: map[string]cloud.CLIResult{ + "sts get-caller-identity": {Stdout: callerIdentityPlainUser}, + }} + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + st, err := p.Identity(context.Background(), f.run, "") + require.NoError(t, err) + + assert.Equal(t, "arn:aws:iam::111122223333:user/operator", st.AssumedIdentity) + assert.False(t, st.Valid, "a plain user ARN means the assume-role pin did not take effect") + assert.NotEmpty(t, st.Hint) +} + +func TestIdentityMatchesExpectedRoleArnWhenPinned(t *testing.T) { + f := &fakeRun{results: map[string]cloud.CLIResult{ + "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, + }} + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + st, err := p.Identity(context.Background(), f.run, "arn:aws:iam::111122223333:role/triagent-readonly") + require.NoError(t, err) + assert.True(t, st.Valid, "assumed-role ARN whose role matches the pinned expectation is valid") + assert.Equal(t, "arn:aws:iam::111122223333:role/triagent-readonly", st.AssumedIdentity, + "a matched pin displays the canonical configured role ARN, not the per-session STS ARN") +} + +func TestIdentityRejectsMismatchedExpectedRoleArn(t *testing.T) { + f := &fakeRun{results: map[string]cloud.CLIResult{ + "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, + }} + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + st, err := p.Identity(context.Background(), f.run, "arn:aws:iam::111122223333:role/some-other-role") + require.NoError(t, err) + assert.False(t, st.Valid, "assumed role not matching the pinned expectation is invalid") + assert.NotEmpty(t, st.Hint) +} + +func TestAssumedRoleARNParsesPartitionsAndPaths(t *testing.T) { + t.Parallel() + cases := []struct { + name string + arn string + want string + ok bool + }{ + { + "commercial", + "arn:aws:sts::111122223333:assumed-role/triagent-readonly/session", + "arn:aws:iam::111122223333:role/triagent-readonly", + true, + }, + { + "gov-cloud", + "arn:aws-us-gov:sts::111122223333:assumed-role/triagent-readonly/session", + "arn:aws-us-gov:iam::111122223333:role/triagent-readonly", + true, + }, + { + "china", + "arn:aws-cn:sts::111122223333:assumed-role/triagent-readonly/session", + "arn:aws-cn:iam::111122223333:role/triagent-readonly", + true, + }, + { + "iam-path", + "arn:aws:sts::111122223333:assumed-role/team/sub/triagent-readonly/session", + "arn:aws:iam::111122223333:role/team/sub/triagent-readonly", + true, + }, + { + "plain-user", + "arn:aws:iam::111122223333:user/operator", + "", + false, + }, + { + "no-session", + "arn:aws:sts::111122223333:assumed-role/triagent-readonly", + "", + false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := assumedRoleARN(tc.arn) + assert.Equal(t, tc.ok, ok) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestIdentityInvalidOnNonZeroExit(t *testing.T) { + f := &fakeRun{results: map[string]cloud.CLIResult{ + "sts get-caller-identity": {ExitCode: 255, Stdout: ""}, + }} + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + st, err := p.Identity(context.Background(), f.run, "") + require.NoError(t, err) + assert.False(t, st.Valid) + assert.NotEmpty(t, st.Hint) +} diff --git a/pkg/mcp/cloud/providers/aws/inventory.go b/pkg/mcp/cloud/providers/aws/inventory.go new file mode 100644 index 00000000..c50b0695 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/inventory.go @@ -0,0 +1,20 @@ +package aws + +import ( + "context" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +// Inventory projects the AWS accounts the agent may reach: exactly the +// configured account set. Each account is its own read-only role, so the +// configured list already describes what run_cli can reach; Inventory returns it +// directly and shells nothing. It never queries `organizations list-accounts` — +// an org-wide listing would over-advertise accounts the roles cannot enter. +func (p *Provider) Inventory(_ context.Context, _ cloud.RunFunc) (cloud.Inventory, error) { + scopes := make([]cloud.Scope, 0, len(p.accounts)) + for _, a := range p.accounts { + scopes = append(scopes, cloud.Scope{ID: a.ID, Name: a.ID, Tags: a.Tags}) + } + return cloud.Inventory{Scopes: scopes}, nil +} diff --git a/pkg/mcp/cloud/providers/aws/inventory_test.go b/pkg/mcp/cloud/providers/aws/inventory_test.go new file mode 100644 index 00000000..769756bc --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/inventory_test.go @@ -0,0 +1,61 @@ +package aws + +import ( + "context" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// failRun fails the test if Inventory shells the CLI: the reachable set is the +// configured accounts, so Inventory must never run a command. +func failRun(t *testing.T) cloud.RunFunc { + return func(_ context.Context, argv []string) (cloud.CLIResult, error) { + t.Fatalf("Inventory must not shell the CLI; got %v", argv) + return cloud.CLIResult{}, nil + } +} + +// TestInventoryUsesConfiguredAccounts proves the reachable set is exactly the +// configured accounts, reported without ever calling organizations list-accounts. +func TestInventoryUsesConfiguredAccounts(t *testing.T) { + p := providerWithAccounts(t, "prod-aws", []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}, + {ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r"}, + }) + inv, err := p.Inventory(context.Background(), failRun(t)) + require.NoError(t, err) + require.Len(t, inv.Scopes, 2) + assert.Equal(t, cloud.Scope{ID: "111111111111", Name: "111111111111"}, inv.Scopes[0]) + assert.Equal(t, cloud.Scope{ID: "222222222222", Name: "222222222222"}, inv.Scopes[1]) +} + +// TestInventoryCarriesAccountTags proves the deployment's per-account tags +// surface on the inventory scopes, so list_inventory can hand the agent the +// labels it uses to judge which account an investigation belongs to. +func TestInventoryCarriesAccountTags(t *testing.T) { + p := providerWithAccounts(t, "prod-aws", []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r", Tags: []string{"prod", "payments"}}, + {ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r"}, + }) + inv, err := p.Inventory(context.Background(), failRun(t)) + require.NoError(t, err) + require.Len(t, inv.Scopes, 2) + assert.Equal(t, []string{"prod", "payments"}, inv.Scopes[0].Tags) + assert.Empty(t, inv.Scopes[1].Tags) + assert.Equal(t, []string{"prod", "payments"}, p.ConfiguredTargets()[0].Tags) +} + +// TestInventorySingleAccountIsOneEntry pins that a single-account source is just +// a one-entry list — the same code path as multi, with one scope. +func TestInventorySingleAccountIsOneEntry(t *testing.T) { + p := providerWithAccounts(t, "prod-aws", []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}, + }) + inv, err := p.Inventory(context.Background(), failRun(t)) + require.NoError(t, err) + require.Len(t, inv.Scopes, 1) + assert.Equal(t, cloud.Scope{ID: "111111111111", Name: "111111111111"}, inv.Scopes[0]) +} diff --git a/pkg/mcp/cloud/providers/aws/profiles.go b/pkg/mcp/cloud/providers/aws/profiles.go new file mode 100644 index 00000000..de7f8ac2 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/profiles.go @@ -0,0 +1,231 @@ +package aws + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// managedProfilesMu serializes managed-profile writes within this process; the +// advisory file lock acquireConfigLock takes serializes them across processes. +// Together they make the read-modify-write of ~/.aws/config atomic against a +// concurrent generation for another alias (a launcher probe and a serve +// subprocess, or two AWS sources, all write the same file). +var managedProfilesMu sync.Mutex + +// profileName is the AWS_PROFILE for one configured account: a deterministic +// triagent-cloud-- so the server's ActiveTargetEnv and the +// generated ~/.aws/config block name the same profile. Exported for reuse by the +// launcher-side env builders, which must pin the same profile the provider +// generated. +func ProfileName(alias, accountID string) string { + return profileName(alias, accountID) +} + +func profileName(alias, accountID string) string { + return "triagent-cloud-" + alias + "-" + accountID +} + +// blockMarkers returns the BEGIN/END comment lines delimiting one alias's +// managed section in ~/.aws/config, so a rewrite replaces exactly that block and +// never touches operator-authored profiles or another alias's block. +func blockMarkers(alias string) (begin, end string) { + return "# BEGIN triagent-cloud-" + alias, "# END triagent-cloud-" + alias +} + +// managedSentinel separates the verbatim copy of the operator's config (above) +// from triagent's generated assume-role profiles (below) in the target file. +const managedSentinel = "# ===== triagent-managed cloud profiles (generated by triagent; do not edit) =====" + +// writeManagedProfiles regenerates the triagent-owned AWS config at targetPath +// from the operator's config at sourcePath, never editing the operator's file. +// The target is a copy of the operator config (with any triagent-managed blocks +// stripped) followed by managedSentinel and one [profile +// triagent-cloud--] block per account, each layering its +// role_arn over sourceProfile. Copying the operator config keeps source_profile +// resolvable; triagent holds no credential — the aws CLI performs the +// assume-role at run time. The managed region accumulates one block per alias, +// so rewriting one alias preserves the others. Serialized across processes by an +// advisory lock on targetPath and written tmp-file-then-rename; it refuses to +// write output the aws CLI could not parse. +func writeManagedProfiles(targetPath, sourcePath, alias, sourceProfile string, accounts []Account) error { + begin, end := blockMarkers(alias) + + var block strings.Builder + block.WriteString(begin) + block.WriteString("\n") + for _, a := range accounts { + fmt.Fprintf(&block, "[profile %s]\n", profileName(alias, a.ID)) + fmt.Fprintf(&block, "role_arn = %s\n", a.RoleARN) + fmt.Fprintf(&block, "source_profile = %s\n", sourceProfile) + } + block.WriteString(end) + block.WriteString("\n") + + managedProfilesMu.Lock() + defer managedProfilesMu.Unlock() + + if err := os.MkdirAll(filepath.Dir(targetPath), 0o700); err != nil { + return fmt.Errorf("aws: create config dir for %s: %w", targetPath, err) + } + release, err := acquireConfigLock(targetPath) + if err != nil { + return err + } + defer release() + + base, err := os.ReadFile(sourcePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("aws: read operator config %s: %w", sourcePath, err) + } + existing, err := os.ReadFile(targetPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("aws: read managed config %s: %w", targetPath, err) + } + + managed := replaceBlock(managedRegion(string(existing)), begin, end, block.String()) + merged := assembleManagedConfig(stripManagedBlocks(string(base)), managed) + // Fail closed: never land content the aws CLI cannot parse. A single stray + // line breaks every profile in the file, so refuse rather than write — + // surfacing the offending line instead of a downstream "sts ... failed". + if bad := firstInvalidConfigLine(merged); bad != "" { + return fmt.Errorf("aws: refusing to write %s: line %q is not valid config (expected a [section], comment, or key=value); fix the operator config %s and retry", targetPath, bad, sourcePath) + } + return atomicWrite(targetPath, []byte(merged)) +} + +// managedRegion returns the generated region of a prior target config: +// everything after managedSentinel, or "" when absent (or when reading the +// operator's own config, which never carries the sentinel). +func managedRegion(content string) string { + i := strings.Index(content, managedSentinel) + if i < 0 { + return "" + } + return strings.TrimPrefix(content[i+len(managedSentinel):], "\n") +} + +// stripManagedBlocks removes every # BEGIN/# END triagent-cloud-* region from +// content, so the operator-config copy never carries a managed block into the +// region above the sentinel — which would duplicate the managed region below it. +// Operator configs normally have none; this keeps the generated file clean if +// one is present. +func stripManagedBlocks(content string) string { + lines := strings.Split(content, "\n") + out := make([]string, 0, len(lines)) + skipping := false + for _, ln := range lines { + t := strings.TrimSpace(ln) + switch { + case !skipping && strings.HasPrefix(t, "# BEGIN triagent-cloud-"): + skipping = true + case skipping && strings.HasPrefix(t, "# END triagent-cloud-"): + skipping = false + case !skipping: + out = append(out, ln) + } + } + return strings.Join(out, "\n") +} + +// assembleManagedConfig joins the (stripped) operator base and the managed +// region under the sentinel. The result always ends with a newline. +func assembleManagedConfig(base, managed string) string { + var b strings.Builder + if base = strings.TrimRight(base, "\n"); base != "" { + b.WriteString(base) + b.WriteString("\n\n") + } + b.WriteString(managedSentinel) + b.WriteString("\n") + if managed = strings.TrimLeft(managed, "\n"); managed != "" && !strings.HasSuffix(managed, "\n") { + managed += "\n" + } + b.WriteString(managed) + return b.String() +} + +// acquireConfigLock takes an exclusive advisory lock on a sibling lock file so +// the read-modify-write below is atomic across processes. It returns a release +// func that unlocks and closes the lock file; the lock file itself is left in +// place (an empty marker), never the config. +func acquireConfigLock(configPath string) (func(), error) { + lockPath := configPath + ".lock" + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("aws: open config lock %s: %w", lockPath, err) + } + if err := lockExclusive(f); err != nil { + _ = f.Close() + return nil, fmt.Errorf("aws: lock config %s: %w", lockPath, err) + } + return func() { + _ = unlockFile(f) + _ = f.Close() + }, nil +} + +// replaceBlock splices block in place of any existing begin..end region in +// content, appending it (after a separating blank line) when no prior block is +// present. Lines outside the region are preserved verbatim, so operator-authored +// profiles survive. +func replaceBlock(content, begin, end, block string) string { + bIdx := strings.Index(content, begin) + if bIdx < 0 { + if content == "" { + return block + } + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + return content + "\n" + block + } + eIdx := strings.Index(content[bIdx:], end) + if eIdx < 0 { + // Truncated prior block (no END): replace from BEGIN to end of file. + return content[:bIdx] + block + } + tailStart := bIdx + eIdx + len(end) + tail := content[tailStart:] + tail = strings.TrimPrefix(tail, "\n") + return content[:bIdx] + block + tail +} + +// firstInvalidConfigLine returns the first structurally-invalid line in an +// ~/.aws/config body, or "" when every line is valid. Used to fail closed +// before writing: the aws CLI rejects the whole file if any line is not blank, +// a comment, a [section] header, or a key=value (which also covers the indented +// sub-keys AWS uses for nested settings). +func firstInvalidConfigLine(content string) string { + for _, ln := range strings.Split(content, "\n") { + t := strings.TrimSpace(ln) + switch { + case t == "": + case strings.HasPrefix(t, "#"), strings.HasPrefix(t, ";"): + case strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]"): + case strings.Contains(t, "="): + default: + return t + } + } + return "" +} + +// atomicWrite writes body to dst via a sibling tmp file then renames it into +// place, so a reader never observes a partially written ~/.aws/config. +func atomicWrite(dst string, body []byte) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return fmt.Errorf("aws: create config dir for %s: %w", dst, err) + } + tmp := dst + ".tmp." + strconv.Itoa(os.Getpid()) + if err := os.WriteFile(tmp, body, 0o600); err != nil { + return fmt.Errorf("aws: write config tmp %s: %w", tmp, err) + } + if err := os.Rename(tmp, dst); err != nil { + return fmt.Errorf("aws: rename config %s: %w", dst, err) + } + return nil +} diff --git a/pkg/mcp/cloud/providers/aws/profiles_test.go b/pkg/mcp/cloud/providers/aws/profiles_test.go new file mode 100644 index 00000000..c0dec7b4 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/profiles_test.go @@ -0,0 +1,168 @@ +package aws + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfileName(t *testing.T) { + assert.Equal(t, "triagent-cloud-prod-aws-111111111111", profileName("prod-aws", "111111111111")) +} + +func writeSource(t *testing.T, dir, body string) string { + t.Helper() + src := filepath.Join(dir, "operator-config") + require.NoError(t, os.WriteFile(src, []byte(body), 0o600)) + return src +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + return string(b) +} + +// TestWriteManagedProfilesGeneratesTargetFromSource proves the target is a +// self-contained copy of the operator config (so source_profile resolves) plus +// the managed blocks, and that the operator's own file is never touched. +func TestWriteManagedProfilesGeneratesTargetFromSource(t *testing.T) { + dir := t.TempDir() + srcBody := "[profile operator-test]\nregion = eu-west-1\n" + src := writeSource(t, dir, srcBody) + target := filepath.Join(dir, "aws", "config") + accs := []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/triage-readonly"}, + {ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/triage-readonly"}, + } + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "operator-test", accs)) + + got := readFile(t, target) + assert.Contains(t, got, "[profile operator-test]", "operator profile must be copied so source_profile resolves") + assert.Contains(t, got, managedSentinel) + assert.Contains(t, got, "[profile triagent-cloud-prod-aws-111111111111]") + assert.Contains(t, got, "[profile triagent-cloud-prod-aws-222222222222]") + assert.Contains(t, got, "source_profile = operator-test") + + assert.Equal(t, srcBody, readFile(t, src), "operator's own config must never be modified") +} + +// TestWriteManagedProfilesIdempotent proves a second write for the same alias +// replaces the prior block rather than appending a duplicate, and does not +// duplicate the operator copy. +func TestWriteManagedProfilesIdempotent(t *testing.T) { + dir := t.TempDir() + src := writeSource(t, dir, "[profile sso-admin]\n") + target := filepath.Join(dir, "config") + accs := []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}} + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "sso-admin", accs)) + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "sso-admin", accs)) + + got := readFile(t, target) + assert.Equal(t, 1, strings.Count(got, "[profile triagent-cloud-prod-aws-111111111111]")) + assert.Equal(t, 1, strings.Count(got, "# BEGIN triagent-cloud-prod-aws")) + assert.Equal(t, 1, strings.Count(got, managedSentinel)) + assert.Equal(t, 1, strings.Count(got, "[profile sso-admin]"), "operator copy must not be duplicated") +} + +// TestWriteManagedProfilesCopiesOperatorConfig proves the operator's profiles +// (e.g. the SSO base the assume-role layers over) are present in the target. +func TestWriteManagedProfilesCopiesOperatorConfig(t *testing.T) { + dir := t.TempDir() + src := writeSource(t, dir, "[profile sso-admin]\nsso_start_url = https://example.awsapps.com/start\n") + target := filepath.Join(dir, "config") + accs := []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}} + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "sso-admin", accs)) + + got := readFile(t, target) + assert.Contains(t, got, "[profile sso-admin]") + assert.Contains(t, got, "sso_start_url = https://example.awsapps.com/start") +} + +// TestWriteManagedProfilesStripsManagedBlocksFromSourceCopy proves that if the +// operator config happens to contain a triagent-cloud block, the copy does not +// carry it above the sentinel (which would duplicate the managed region). +func TestWriteManagedProfilesStripsManagedBlocksFromSourceCopy(t *testing.T) { + dir := t.TempDir() + src := writeSource(t, dir, "[profile operator-test]\nregion = eu-west-1\n\n"+ + "# BEGIN triagent-cloud-old\n[profile triagent-cloud-old-1]\nrole_arn = arn:old\nsource_profile = operator-test\n# END triagent-cloud-old\n") + target := filepath.Join(dir, "config") + require.NoError(t, writeManagedProfiles(target, src, "aws-camunda", "operator-test", + []Account{{ID: "095352988152", RoleARN: "arn:aws:iam::095352988152:role/triagent-readonly"}})) + + got := readFile(t, target) + assert.NotContains(t, got, "triagent-cloud-old", "a managed block in the source must not be carried into the copy") + assert.Contains(t, got, "[profile triagent-cloud-aws-camunda-095352988152]") + assert.Contains(t, got, "[profile operator-test]") +} + +// TestWriteManagedProfilesTwoAliasesCarryForward proves two aliases' blocks +// coexist in the managed region: rewriting one preserves the other. +func TestWriteManagedProfilesTwoAliasesCarryForward(t *testing.T) { + dir := t.TempDir() + src := writeSource(t, dir, "[profile base]\n") + target := filepath.Join(dir, "config") + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "base", + []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}})) + require.NoError(t, writeManagedProfiles(target, src, "staging-aws", "base", + []Account{{ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r"}})) + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "base", + []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}})) + + got := readFile(t, target) + assert.Equal(t, 1, strings.Count(got, "# BEGIN triagent-cloud-prod-aws")) + assert.Equal(t, 1, strings.Count(got, "# BEGIN triagent-cloud-staging-aws")) + assert.Equal(t, 1, strings.Count(got, "[profile base]")) +} + +// TestWriteManagedProfilesConcurrentAliasesSurvive pins that concurrent +// generation for different aliases into the same target does not drop a block: +// the read-modify-write is serialized, so every alias's managed section survives. +func TestWriteManagedProfilesConcurrentAliasesSurvive(t *testing.T) { + dir := t.TempDir() + src := writeSource(t, dir, "[profile sso-admin]\n") + target := filepath.Join(dir, "config") + aliases := []string{"alpha", "bravo", "charlie", "delta", "echo"} + + var wg sync.WaitGroup + for _, alias := range aliases { + wg.Add(1) + go func(a string) { + defer wg.Done() + assert.NoError(t, writeManagedProfiles(target, src, a, "sso-admin", []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/" + a}, + })) + }(alias) + } + wg.Wait() + + got := readFile(t, target) + for _, a := range aliases { + begin, _ := blockMarkers(a) + assert.Contains(t, got, begin, "alias %q block must survive concurrent generation", a) + } +} + +// TestWriteManagedProfilesRefusesUnparseableConfig pins the fail-closed +// guarantee: when the operator config carries a stray fragment, triagent refuses +// to write the target (rather than landing something the aws CLI cannot parse) +// and never creates the file. +func TestWriteManagedProfilesRefusesUnparseableConfig(t *testing.T) { + dir := t.TempDir() + src := writeSource(t, dir, "[profile operator-test]\nregion = eu-west-1\n\n-test\n") + target := filepath.Join(dir, "config") + + err := writeManagedProfiles(target, src, "aws-camunda", "operator-test", + []Account{{ID: "095352988152", RoleARN: "arn:aws:iam::095352988152:role/r"}}) + require.Error(t, err, "must refuse when the merged result is unparseable") + assert.Contains(t, err.Error(), "-test", "error should name the offending line") + + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr), "target must not be written when the write is refused") +} diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go new file mode 100644 index 00000000..d51c4746 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -0,0 +1,237 @@ +// Package aws implements the cloud.Provider contract over the read-only aws CLI. +// It ships the AWS default command allowlist, the AWS-specific deny-floor +// additions, the env names the aws subprocess needs, and the projection parsers +// for identity and inventory. It never shells the CLI directly: every invocation +// goes through the cloud.RunFunc the harness injects. +// +// The pinned identity is realized by the launcher through AWS_PROFILE: a profile +// whose role_arn is the deployment's read-only role, with the operator's base +// credentials as source_profile. The provider never selects the profile; the +// --profile flag stays on the agent deny floor. +package aws + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +//go:embed default_commands.json +var defaultCommandsJSON []byte + +// EnvProfile is the env var the launcher sets to select the assume-role profile +// whose role_arn is the deployment's read-only role (with the operator's base +// credentials as source_profile). The provider reads it through the CLI, never +// sets it; the --profile flag stays on the agent deny floor so the agent can +// never select the profile itself. +const EnvProfile = "AWS_PROFILE" + +// Provider satisfies the cloud.Provider contract. +var _ cloud.Provider = (*Provider)(nil) + +// AWS account scoping decision (bubble-up from #45): the cloud package's +// ScopeAllowlist.Accounts field is not enforced in validateArgv, and AWS has no +// single --account flag to scope on. In the operator-ambient model the account +// is fixed by the assume-role profile (AWS_PROFILE): the pinned identity can only +// act in the account(s) its role grants, so the identity itself constrains the +// account and argv-level account scoping is unnecessary here. Region scoping +// (the --region/--zone axis) is still enforced by validateArgv against +// ScopeAllowlist.Regions. If a future deployment needs sub-account argv scoping, +// it belongs in the shared validateArgv, not in this provider. + +// Account is one configured aws account the agent may make active: the account +// id it selects by, the read-only role_arn triagent generates an assume-role +// profile for (layered over the source's SSO base), and the deployment's +// free-form tags surfaced by list_inventory so the agent can judge relevance. +type Account struct { + ID string + RoleARN string + Tags []string +} + +// Options carries the multi-account config the launcher threads through from the +// profile's cloud source: the source alias (the generated profiles' namespace), +// the operator's SSO source_profile, and the account set. The zero value is the +// single-account legacy form — no generated profiles, the selectable set comes +// from inventory. +type Options struct { + Alias string + SourceProfile string + Accounts []Account + // ConfigTargetPath is the triagent-owned AWS config New generates (the cloud + // MCP reads it via AWS_CONFIG_FILE). Required when Accounts is non-empty. + ConfigTargetPath string + // ConfigSourcePath is the operator's AWS config copied into the target so + // source_profile resolves. Defaults to $HOME/.aws/config when empty. + ConfigSourcePath string +} + +// Provider is the AWS realization of cloud.Provider. binary is resolved once at +// construction (overridable in tests); allowlist is the parsed embedded default. +// alias and accounts carry the multi-account config: ConfiguredTargets surfaces +// the accounts as the selectable set, and ActiveTargetEnv names each account's +// generated profile. +type Provider struct { + binary string + allowlist *cloud.CommandAllowlist + alias string + accounts []Account +} + +// New constructs the AWS provider, resolving aws to an absolute path once via +// exec.LookPath so a poisoned PATH cannot redirect the binary at run time. A +// PATH with relative entries makes LookPath return a relative path (flagged with +// exec.ErrDot); the path is made absolute so a later subprocess env/PATH change +// cannot reinterpret it against a different working directory. +// +// When opts carries accounts, New generates the per-account assume-role profiles +// into the triagent-owned ConfigTargetPath (a copy of the operator's config plus +// the managed blocks) before returning, so the profiles exist for both the serve +// subprocess and any launcher-side probe that runs the CLI under AWS_PROFILE. It +// never edits the operator's ~/.aws/config. Generation is idempotent: repeated +// construction (serve and launcher both build the provider) replaces the alias's +// managed block. +func New(opts ...Options) (*Provider, error) { + bin, err := exec.LookPath("aws") + if err != nil && !errors.Is(err, exec.ErrDot) { + return nil, fmt.Errorf("aws: resolve aws binary: %w", err) + } + abs, err := filepath.Abs(bin) + if err != nil { + return nil, fmt.Errorf("aws: resolve aws binary to absolute path: %w", err) + } + return newWithBinary(abs, opts...) +} + +// newWithBinary builds the provider against an already-resolved binary path. It +// is the seam tests inject a fixed path through, bypassing exec.LookPath. At most +// one Options is honored. +func newWithBinary(binary string, opts ...Options) (*Provider, error) { + var list cloud.CommandAllowlist + if err := json.Unmarshal(defaultCommandsJSON, &list); err != nil { + return nil, fmt.Errorf("aws: parse default allowlist: %w", err) + } + var o Options + if len(opts) > 0 { + o = opts[0] + } + if len(o.Accounts) > 0 { + if o.ConfigTargetPath == "" { + return nil, fmt.Errorf("aws: ConfigTargetPath is required to generate account profiles") + } + source := o.ConfigSourcePath + if source == "" { + source = defaultOperatorConfigPath() + } + if err := writeManagedProfiles(o.ConfigTargetPath, source, o.Alias, o.SourceProfile, o.Accounts); err != nil { + return nil, fmt.Errorf("aws: generate account profiles: %w", err) + } + } + return &Provider{binary: binary, allowlist: &list, alias: o.Alias, accounts: o.Accounts}, nil +} + +// defaultOperatorConfigPath is the operator's standard AWS config location, used +// as the copy source when ConfigSourcePath is unset. It is $HOME/.aws/config, +// not $AWS_CONFIG_FILE — under the launcher, AWS_CONFIG_FILE points at the +// generated target, so reading it as the source would be circular. +func defaultOperatorConfigPath() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".aws", "config") +} + +// Name reports the provider identifier. +func (p *Provider) Name() string { return "aws" } + +// Binary is the resolved absolute path to the aws CLI. +func (p *Provider) Binary() string { return p.binary } + +// DefaultAllowlist is the embedded default command allowlist: read-only +// describe/get/list/lookup verbs across the investigative axes (inventory, +// reachability, permissions, cluster, logs, audit). +func (p *Provider) DefaultAllowlist() *cloud.CommandAllowlist { return p.allowlist } + +// DenyFloorAdditions contributes the AWS-specific subcommands that return secret +// material, object contents, decrypted plaintext, or shell access beyond the base +// floor. The base floor prefix-matches top-level tokens, so it never reaches +// these nested verbs; each is listed by its full token-wise path. Metadata reads +// under the same services (describe-secret, list-secrets, head-object, +// describe-parameters, describe-key) are deliberately absent: the floor targets +// secret VALUES, object CONTENTS, and decryption, not listing or describing. +func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { + return cloud.DenyFloor{ + Subcommands: []string{ + "ec2 get-password-data", + "ec2-instance-connect send-ssh-public-key", + "ec2-instance-connect send-serial-console-ssh-public-key", + "sts get-session-token", + "sts get-federation-token", + "secretsmanager get-secret-value", + "s3 cp", + "s3 mv", + "s3 sync", + "s3api get-object", + "s3api get-object-attributes", + "s3api get-object-torrent", + "kms decrypt", + "ssm get-parameter", + "ssm get-parameters", + "ssm get-parameters-by-path", + }, + } +} + +// ConfiguredTargets is the configured account set surfaced as the agent's +// selectable targets. A configured account's id is both the Target ID (what +// set_active_target receives) and its Name. A single-account source is a +// one-entry list, so the set is never derived from a live inventory query. +func (p *Provider) ConfiguredTargets() []cloud.Target { + out := make([]cloud.Target, 0, len(p.accounts)) + for _, a := range p.accounts { + out = append(out, cloud.Target{ID: a.ID, Name: a.ID, Tags: a.Tags}) + } + return out +} + +// ActiveTargetEnv pins the aws CLI to the active account via AWS_PROFILE, naming +// the generated assume-role profile (triagent-cloud--). The +// value is a profile name, not a credential: the CLI performs the assume-role +// from the operator's base. +func (p *Provider) ActiveTargetEnv(id string) []string { + return []string{EnvProfile + "=" + profileName(p.alias, id)} +} + +// ExpectedIdentity returns the read-only role ARN configured for the account +// id, so session_status validates the active account against its own role on +// switch rather than a single source-level identity. ok is false for an id not +// in the configured set, leaving the server to fall back to its pinned identity. +func (p *Provider) ExpectedIdentity(id string) (string, bool) { + for _, a := range p.accounts { + if a.ID == id { + return a.RoleARN, true + } + } + return "", false +} + +// EnvPassthrough lists the env var NAMES the aws subprocess needs forwarded: +// AWS_PROFILE pins the assume-role identity; the region and config-file names +// let the launcher point the CLI at the right account/config without the agent +// supplying them as argv. PATH and HOME are forwarded by the harness base set. +func (p *Provider) EnvPassthrough() []string { + return []string{ + EnvProfile, + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + } +} diff --git a/pkg/mcp/cloud/providers/aws/provider_test.go b/pkg/mcp/cloud/providers/aws/provider_test.go new file mode 100644 index 00000000..87feb79e --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/provider_test.go @@ -0,0 +1,260 @@ +package aws + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewResolvesProvider(t *testing.T) { + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + require.NotNil(t, p) + + assert.Equal(t, "aws", p.Name()) + assert.Equal(t, "/usr/bin/aws", p.Binary()) +} + +// TestNewResolvesBinaryToAbsolutePath proves New stores an absolute binary path +// even when PATH resolution would yield a relative one, so a later subprocess +// env/PATH change cannot redirect what executes. The CLI is dropped into a temp +// dir reachable through a relative PATH entry; the resolved binary must come +// back absolute. +func TestNewResolvesBinaryToAbsolutePath(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "aws") + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755)) + + cwd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + require.NoError(t, os.Chdir(dir)) + + // "." is a relative PATH entry; exec.LookPath("aws") resolves to "aws" + // (relative) under it. + t.Setenv("PATH", ".") + + p, err := New() + require.NoError(t, err) + assert.True(t, filepath.IsAbs(p.Binary()), + "New must store an absolute binary path, got %q", p.Binary()) +} + +func TestDefaultAllowlistCoversReadOnlyAxes(t *testing.T) { + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + allow := p.DefaultAllowlist() + require.NotNil(t, allow) + require.NotEmpty(t, allow.Commands) + + // The two commands Identity and Inventory shell through the validated run + // core must be present, or those tools cannot work under the allowlist. + assert.True(t, allow.Allows([]string{"sts", "get-caller-identity"}), + "sts get-caller-identity must be allowlisted (the identity probe runs it)") + assert.True(t, allow.Allows([]string{"organizations", "list-accounts"}), + "organizations list-accounts must be allowlisted (a read-only command the agent may run)") + + // Spot-check coverage across the investigative axes. + for _, argv := range [][]string{ + {"ec2", "describe-security-groups"}, + {"ec2", "describe-route-tables"}, + {"iam", "list-roles"}, + {"eks", "describe-cluster"}, + {"logs", "describe-log-groups"}, + {"cloudtrail", "lookup-events"}, + } { + assert.Truef(t, allow.Allows(argv), "%v must be allowlisted", argv) + } + + // Every entry must be a read-only verb and carry an axis description. + for _, c := range allow.Commands { + assert.NotEmpty(t, c.Description, "command %q must name its axis", c.Path) + } +} + +func TestDenyFloorAdditionsCoverCredentialReturningCommands(t *testing.T) { + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + floor := p.DenyFloorAdditions() + assert.Contains(t, floor.Subcommands, "ec2 get-password-data") + assert.Contains(t, floor.Subcommands, "ec2-instance-connect send-ssh-public-key") + assert.Contains(t, floor.Subcommands, "sts get-session-token") + assert.Contains(t, floor.Subcommands, "sts get-federation-token") +} + +// TestDenyFloorDropsNestedExfilSecretDecryptOverrides asserts that even a +// profile override that tries to allowlist a nested secret-value / object-content +// / decrypt command is dropped by the AWS deny floor: the value-returning verb is +// floored, while metadata-only reads under the same service stay allowable. +func TestDenyFloorDropsNestedExfilSecretDecryptOverrides(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + floored := [][]string{ + {"secretsmanager", "get-secret-value"}, + {"s3", "cp"}, + {"s3", "mv"}, + {"s3", "sync"}, + {"s3api", "get-object"}, + {"s3api", "get-object-attributes"}, + {"s3api", "get-object-torrent"}, + {"kms", "decrypt"}, + {"ssm", "get-parameter"}, + {"ssm", "get-parameters"}, + {"ssm", "get-parameters-by-path"}, + } + // Metadata-only reads under the same services must remain allowable: the + // floor targets secret VALUES, object CONTENTS, and decryption, not listing + // or describing. + metadataOnly := [][]string{ + {"secretsmanager", "describe-secret"}, + {"secretsmanager", "list-secrets"}, + {"s3api", "head-object"}, + {"s3api", "list-objects-v2"}, + {"ssm", "describe-parameters"}, + {"kms", "describe-key"}, + } + + override := allowlistJSON(t, append(append([][]string{}, floored...), metadataOnly...)) + loaded, err := cloud.LoadCommandAllowlist(override, p.DenyFloorAdditions()) + require.NoError(t, err) + + for _, argv := range floored { + assert.Falsef(t, loaded.Allows(argv), "override must not re-enable floored %v", argv) + } + for _, argv := range metadataOnly { + assert.Truef(t, loaded.Allows(argv), "metadata-only %v must stay allowable", argv) + } +} + +// allowlistJSON writes a command allowlist document with the given subcommand +// paths to a temp file and returns its path, the seam LoadCommandAllowlist reads +// a profile override through. +func allowlistJSON(t *testing.T, paths [][]string) string { + t.Helper() + var doc cloud.CommandAllowlist + for _, p := range paths { + doc.Commands = append(doc.Commands, cloud.Command{Path: strings.Join(p, " "), Description: "test"}) + } + b, err := json.Marshal(doc) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "commands.json") + require.NoError(t, os.WriteFile(path, b, 0o600)) + return path +} + +func TestEnvPassthroughForwardsProfileAndRegionNames(t *testing.T) { + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + got := p.EnvPassthrough() + for _, name := range []string{ + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + } { + assert.Contains(t, got, name) + } + // PATH/HOME are forwarded by the harness base set; the provider must not + // duplicate them. + assert.NotContains(t, got, "PATH") + assert.NotContains(t, got, "HOME") +} + +// fakeRun returns a canned CLIResult/error for a given argv, recording the argv +// it was called with so a test can assert the projection drove the right CLI. +type fakeRun struct { + results map[string]cloud.CLIResult + errs map[string]error + calls [][]string +} + +func (f *fakeRun) run(_ context.Context, argv []string) (cloud.CLIResult, error) { + f.calls = append(f.calls, argv) + key := keyOf(argv) + if err, ok := f.errs[key]; ok { + return cloud.CLIResult{}, err + } + return f.results[key], nil +} + +func keyOf(argv []string) string { + out := "" + for _, a := range argv { + if len(a) > 0 && a[0] == '-' { + break + } + if out != "" { + out += " " + } + out += a + } + return out +} + +func TestConfiguredTargetsEmptyWithoutAccounts(t *testing.T) { + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + assert.Empty(t, p.ConfiguredTargets()) +} + +func TestConfiguredTargetsFromAccounts(t *testing.T) { + p := providerWithAccounts(t, "prod-aws", []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}, + {ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r"}, + }) + targets := p.ConfiguredTargets() + require.Len(t, targets, 2) + assert.Equal(t, "111111111111", targets[0].ID) + assert.Equal(t, "111111111111", targets[0].Name) + assert.Equal(t, "222222222222", targets[1].ID) +} + +func TestActiveTargetEnvUsesGeneratedProfileName(t *testing.T) { + p := providerWithAccounts(t, "prod-aws", []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}, + }) + assert.Equal(t, []string{"AWS_PROFILE=triagent-cloud-prod-aws-111111111111"}, p.ActiveTargetEnv("111111111111")) +} + +func TestExpectedIdentityReturnsAccountRoleARN(t *testing.T) { + p := providerWithAccounts(t, "prod-aws", []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r1"}, + {ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r2"}, + }) + exp, ok := p.ExpectedIdentity("222222222222") + require.True(t, ok, "a configured account must yield its expected role ARN") + assert.Equal(t, "arn:aws:iam::222222222222:role/r2", exp) + + _, ok = p.ExpectedIdentity("999999999999") + assert.False(t, ok, "an account outside the set yields no expected identity") +} + +// providerWithAccounts builds an aws provider whose generated-profile config is +// written to a temp target from an empty temp source, so construction's +// writeManagedProfiles call never touches the developer's ~/.aws/config. +func providerWithAccounts(t *testing.T, alias string, accs []Account) *Provider { + t.Helper() + dir := t.TempDir() + p, err := newWithBinary("/usr/bin/aws", Options{ + Alias: alias, + SourceProfile: "sso-admin", + Accounts: accs, + ConfigTargetPath: filepath.Join(dir, "aws", "config"), + ConfigSourcePath: filepath.Join(dir, "operator-config"), + }) + require.NoError(t, err) + return p +} diff --git a/pkg/mcp/cloud/providers/gcp/default_commands.json b/pkg/mcp/cloud/providers/gcp/default_commands.json new file mode 100644 index 00000000..89066a21 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/default_commands.json @@ -0,0 +1,36 @@ +{ + "commands": [ + { "path": "projects list", "description": "inventory: list the projects the pinned identity can see" }, + { "path": "projects describe", "description": "inventory: project metadata, lifecycle state, and labels" }, + { "path": "projects get-iam-policy", "description": "permissions: the IAM policy bound on a project" }, + + { "path": "compute instances list", "description": "inventory: compute instances in a project" }, + { "path": "compute instances describe", "description": "reachability: an instance's network interfaces, tags, and service account" }, + { "path": "compute networks list", "description": "reachability: VPC networks in a project" }, + { "path": "compute networks describe", "description": "reachability: a VPC network's subnet and peering layout" }, + { "path": "compute networks subnets list", "description": "reachability: subnets and their CIDR ranges" }, + { "path": "compute networks subnets describe", "description": "reachability: a subnet's range, region, and secondary ranges" }, + { "path": "compute firewall-rules list", "description": "reachability: firewall rules governing traffic to a workload" }, + { "path": "compute firewall-rules describe", "description": "reachability: a firewall rule's direction, ports, and target tags" }, + { "path": "compute routes list", "description": "reachability: routes that steer egress out of a network" }, + { "path": "compute routes describe", "description": "reachability: a single route's next-hop and priority" }, + { "path": "compute addresses list", "description": "reachability: reserved internal and external IP addresses" }, + { "path": "compute forwarding-rules list", "description": "reachability: load-balancer forwarding rules and their backends" }, + + { "path": "container clusters list", "description": "cluster: GKE clusters and their endpoints in a project" }, + { "path": "container clusters describe", "description": "cluster: a GKE cluster's networking, workload-identity, and node config" }, + { "path": "container node-pools list", "description": "cluster: node pools backing a GKE cluster" }, + { "path": "container node-pools describe", "description": "cluster: a node pool's machine type, autoscaling, and image config" }, + + { "path": "iam service-accounts list", "description": "permissions: service accounts defined in a project" }, + { "path": "iam service-accounts describe", "description": "permissions: a service account's display name and disabled state" }, + { "path": "iam service-accounts get-iam-policy", "description": "permissions: who can impersonate or manage a service account" }, + { "path": "iam roles describe", "description": "permissions: the permissions a role grants" }, + + { "path": "logging read", "description": "logs: read entries from a project's log buckets with a filter" }, + { "path": "logging logs list", "description": "audit: enumerate available log streams, including data_access and activity audit logs" }, + { "path": "logging sinks list", "description": "audit: where log entries are routed for retention" }, + + { "path": "monitoring dashboards list", "description": "cluster: monitoring dashboards configured for the project" } + ] +} diff --git a/pkg/mcp/cloud/providers/gcp/identity.go b/pkg/mcp/cloud/providers/gcp/identity.go new file mode 100644 index 00000000..cb218aa5 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/identity.go @@ -0,0 +1,56 @@ +package gcp + +import ( + "context" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +// Identity is the read-only whoami. It is called by cloud.Probe with an +// unvalidated RunFunc, so it may use the deny-floored `auth` subcommand +// directly. Validity means "impersonation is pinned to the expected SA and the +// pin actually works", not "logged in directly as the SA": gcloud impersonation +// keeps the operator's base account active while every call assumes the target +// SA, so comparing the base account to the target would mark a correctly +// configured session invalid. +// +// The probe runs a minimal impersonated read through the RunFunc to prove the +// pin took effect. The impersonation env lives on the MCP subprocess the RunFunc +// shells out to (the launcher sets it there; the agent cannot reach it), so the +// proof rides on the read alone — the launcher process that drives the preflight +// probe never carries that env in its own os.Environ. A degraded auth state +// surfaces through Valid and Hint, never a Go error. +// +// NOTE: validated against gcloud's documented impersonation behavior; verify +// against a live gcloud before relying on the exact print-access-token shape. +func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc, expected string) (cloud.IdentityStatus, error) { + st := cloud.IdentityStatus{Provider: "gcp"} + + if expected == "" { + st.Valid = false + st.Hint = "no impersonation target pinned; set " + EnvImpersonate + " on the cloud MCP subprocess" + return st, nil + } + + // Minimal impersonated read: succeeds only when the pinned SA can mint a + // token, which proves the impersonation grant is in place and active. + res, err := run(ctx, []string{"auth", "print-access-token", "--format=json"}) + if err != nil { + st.Valid = false + st.Hint = err.Error() + return st, nil + } + if res.ExitCode != 0 { + st.Valid = false + hint := "impersonation failed; check the serviceAccountTokenCreator grant or re-auth: gcloud auth login" + if res.Stderr != "" { + hint = res.Stderr + " — " + hint + } + st.Hint = hint + return st, nil + } + + st.AssumedIdentity = expected + st.Valid = true + return st, nil +} diff --git a/pkg/mcp/cloud/providers/gcp/identity_test.go b/pkg/mcp/cloud/providers/gcp/identity_test.go new file mode 100644 index 00000000..f0324f9f --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/identity_test.go @@ -0,0 +1,82 @@ +package gcp + +import ( + "context" + "errors" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const targetSA = "ro-sa@proj.iam.gserviceaccount.com" + +// fakeRun returns a canned CLIResult/error for a given argv, recording the +// argv it was called with so a test can assert the probe drove the right CLI. +type fakeRun struct { + result cloud.CLIResult + err error + calls [][]string +} + +func (f *fakeRun) run(_ context.Context, argv []string) (cloud.CLIResult, error) { + f.calls = append(f.calls, argv) + return f.result, f.err +} + +func TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + f := &fakeRun{result: cloud.CLIResult{Stdout: `"token"`}} + st, err := p.Identity(context.Background(), f.run, "") + require.NoError(t, err) + assert.False(t, st.Valid, "no pinned target means the session is not validly pinned") + assert.NotEmpty(t, st.Hint) + assert.Empty(t, f.calls, "no probe should run without a target") +} + +func TestIdentityValidWhenImpersonatedReadSucceeds(t *testing.T) { + // No t.Setenv: the launcher injects the impersonation env only into the + // MCP subprocess, never into its own process. The probe runs launcher-side + // here with no ambient env, so validity must rest on the impersonated read + // the fake RunFunc returns, not on os.Getenv. + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + f := &fakeRun{result: cloud.CLIResult{Stdout: `"ya29.token"`}} + st, err := p.Identity(context.Background(), f.run, targetSA) + require.NoError(t, err) + assert.Equal(t, "gcp", st.Provider) + assert.Equal(t, targetSA, st.AssumedIdentity, "the SA is the identity the session acts as") + assert.True(t, st.Valid, "a successful impersonated read proves the pin took effect") + require.Len(t, f.calls, 1) + assert.Equal(t, []string{"auth", "print-access-token", "--format=json"}, f.calls[0]) +} + +func TestIdentityInvalidWhenImpersonatedReadFails(t *testing.T) { + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + f := &fakeRun{result: cloud.CLIResult{ + ExitCode: 1, + Stderr: "ERROR: Permission 'iam.serviceAccounts.getAccessToken' denied", + }} + st, err := p.Identity(context.Background(), f.run, targetSA) + require.NoError(t, err) + assert.False(t, st.Valid, "a failed impersonated read means the pin did not take effect") + assert.Contains(t, st.Hint, "iam.serviceAccounts.getAccessToken", + "the hint surfaces the captured stderr") +} + +func TestIdentitySurfacesRunErrorAsHint(t *testing.T) { + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + f := &fakeRun{err: errors.New("gcloud not authenticated")} + st, err := p.Identity(context.Background(), f.run, targetSA) + require.NoError(t, err, "a degraded auth state surfaces through Valid/Hint, not a Go error") + assert.False(t, st.Valid) + assert.Contains(t, st.Hint, "gcloud not authenticated") +} diff --git a/pkg/mcp/cloud/providers/gcp/inventory.go b/pkg/mcp/cloud/providers/gcp/inventory.go new file mode 100644 index 00000000..95a1933f --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/inventory.go @@ -0,0 +1,51 @@ +package gcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +// project is one entry of `gcloud projects list --format=json`. Only the fields +// the inventory projection surfaces are decoded. +type project struct { + ProjectID string `json:"projectId"` + Name string `json:"name"` +} + +// Inventory lists the projects the agent may reach. When the deployment +// configured a project set, that set (with its tags) is the inventory and +// nothing is shelled — it is exactly what the agent can select among. Only when +// no projects are configured (the unconstrained form) does it list projects live +// via `gcloud projects list` (untagged). A live run error is a real failure of +// the inventory tool and is returned, unlike the identity probe which degrades. +func (p *Provider) Inventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { + if len(p.projects) > 0 { + scopes := make([]cloud.Scope, 0, len(p.projects)) + for _, pr := range p.projects { + scopes = append(scopes, cloud.Scope{ID: pr.ID, Name: pr.ID, Tags: pr.Tags}) + } + return cloud.Inventory{Scopes: scopes}, nil + } + + res, err := run(ctx, []string{"projects", "list", "--format=json"}) + if err != nil { + return cloud.Inventory{}, fmt.Errorf("gcloud projects list: %w", err) + } + if res.ExitCode != 0 { + return cloud.Inventory{}, fmt.Errorf("gcloud projects list failed (exit %d): %s", res.ExitCode, res.Stderr) + } + + var projects []project + if err := json.Unmarshal([]byte(res.Stdout), &projects); err != nil { + return cloud.Inventory{}, fmt.Errorf("parse gcloud projects list output: %w", err) + } + + inv := cloud.Inventory{Scopes: make([]cloud.Scope, 0, len(projects))} + for _, pr := range projects { + inv.Scopes = append(inv.Scopes, cloud.Scope{ID: pr.ProjectID, Name: pr.Name}) + } + return inv, nil +} diff --git a/pkg/mcp/cloud/providers/gcp/inventory_test.go b/pkg/mcp/cloud/providers/gcp/inventory_test.go new file mode 100644 index 00000000..84c34b22 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/inventory_test.go @@ -0,0 +1,135 @@ +package gcp + +import ( + "context" + "errors" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// projectsListJSON is captured `gcloud projects list --format=json` output. +const projectsListJSON = `[ + { + "projectId": "triage-prod", + "name": "Triage Production", + "projectNumber": "111111111111", + "lifecycleState": "ACTIVE" + }, + { + "projectId": "triage-staging", + "name": "Triage Staging", + "projectNumber": "222222222222", + "lifecycleState": "ACTIVE" + } +]` + +func runReturning(out string) cloud.RunFunc { + return func(context.Context, []string) (cloud.CLIResult, error) { + return cloud.CLIResult{Stdout: out}, nil + } +} + +// TestInventoryUsesConfiguredProjects proves that a provider built with a +// configured project set reports exactly those projects (with their tags) and +// shells nothing — the live RunFunc must never be invoked. +func TestInventoryUsesConfiguredProjects(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud", Options{Projects: []Project{ + {ID: "prod-platform", Tags: []string{"prod", "payments"}}, + {ID: "prod-data"}, + }}) + require.NoError(t, err) + + failRun := func(_ context.Context, argv []string) (cloud.CLIResult, error) { + t.Fatalf("Inventory must not shell the CLI when projects are configured; got %v", argv) + return cloud.CLIResult{}, nil + } + inv, err := p.Inventory(context.Background(), failRun) + require.NoError(t, err) + require.Len(t, inv.Scopes, 2) + assert.Equal(t, cloud.Scope{ID: "prod-platform", Name: "prod-platform", Tags: []string{"prod", "payments"}}, inv.Scopes[0]) + assert.Equal(t, cloud.Scope{ID: "prod-data", Name: "prod-data"}, inv.Scopes[1]) +} + +// TestConfiguredTargetsFromProjects proves the selectable set mirrors the +// configured projects, tags included; empty config yields no configured targets +// (the server then falls back to live inventory). +func TestConfiguredTargetsFromProjects(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud", Options{Projects: []Project{ + {ID: "prod-platform", Tags: []string{"prod"}}, + }}) + require.NoError(t, err) + require.Len(t, p.ConfiguredTargets(), 1) + assert.Equal(t, cloud.Target{ID: "prod-platform", Name: "prod-platform", Tags: []string{"prod"}}, p.ConfiguredTargets()[0]) + + bare, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + assert.Empty(t, bare.ConfiguredTargets()) +} + +func TestInventoryProjectsIDAndName(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + inv, err := p.Inventory(context.Background(), runReturning(projectsListJSON)) + require.NoError(t, err) + require.Len(t, inv.Scopes, 2) + assert.Equal(t, cloud.Scope{ID: "triage-prod", Name: "Triage Production"}, inv.Scopes[0]) + assert.Equal(t, cloud.Scope{ID: "triage-staging", Name: "Triage Staging"}, inv.Scopes[1]) +} + +func TestInventoryEmptyWhenNoProjects(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + inv, err := p.Inventory(context.Background(), runReturning(`[]`)) + require.NoError(t, err) + assert.Empty(t, inv.Scopes) +} + +func TestInventoryCallsProjectsListWithJSONFormat(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + var gotArgv []string + capturing := cloud.RunFunc(func(_ context.Context, argv []string) (cloud.CLIResult, error) { + gotArgv = argv + return cloud.CLIResult{Stdout: projectsListJSON}, nil + }) + _, err = p.Inventory(context.Background(), capturing) + require.NoError(t, err) + assert.Equal(t, []string{"projects", "list", "--format=json"}, gotArgv, + "the inventory argv must match the allowlisted `projects list` verb chain exactly") +} + +func TestInventoryErrorsOnNonZeroExit(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + failing := cloud.RunFunc(func(context.Context, []string) (cloud.CLIResult, error) { + return cloud.CLIResult{ExitCode: 1, Stderr: "ERROR: (gcloud.projects.list) PERMISSION_DENIED"}, nil + }) + _, err = p.Inventory(context.Background(), failing) + require.Error(t, err, "a non-zero exit is a real failure, not a parse error") + assert.Contains(t, err.Error(), "PERMISSION_DENIED", "the error surfaces the captured stderr") +} + +func TestInventoryErrorsWhenRunErrors(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + failing := cloud.RunFunc(func(context.Context, []string) (cloud.CLIResult, error) { + return cloud.CLIResult{}, errors.New("projects list rejected") + }) + _, err = p.Inventory(context.Background(), failing) + require.Error(t, err, "a run error is a real failure of the inventory tool, surfaced to the caller") +} diff --git a/pkg/mcp/cloud/providers/gcp/provider.go b/pkg/mcp/cloud/providers/gcp/provider.go new file mode 100644 index 00000000..14b13210 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -0,0 +1,157 @@ +// Package gcp implements the cloud.Provider contract over the gcloud CLI. It is +// selected by --provider=gcp and plugged into the cloud-context MCP behind the +// Provider interface (the teleport DI pattern); it never reaches into the parent +// cloud package's harness. All cloud access shells gcloud through the injected +// cloud.RunFunc — there is no cloud.google.com/go SDK dependency. +package gcp + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +// defaultCommandsJSON is the embedded read-only gcloud command allowlist. Each +// entry's description names the investigative axis it serves. The exact-match +// allowlist requires the complete invariant verb chain per entry. +// +//go:embed default_commands.json +var defaultCommandsJSON []byte + +// EnvImpersonate is the env var the launcher sets to pin the read-only +// service account gcloud impersonates. The provider reads it (never sets it) to +// learn which identity Identity must resolve to; it is on the agent deny floor +// as a flag, so the agent can never select it. +const EnvImpersonate = "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT" + +var _ cloud.Provider = (*Provider)(nil) + +// Project is one deployment-configured selectable project: the project id the +// agent selects by, and the free-form tags surfaced by list_inventory so it can +// judge relevance. +type Project struct { + ID string + Tags []string +} + +// Options carries the gcp config the launcher threads from the profile: the +// deployment's selectable projects and their tags. The zero value (no projects) +// is the unconstrained form — the provider lists projects live instead. +type Options struct { + Projects []Project +} + +// Provider implements cloud.Provider over the gcloud CLI. projects is the +// deployment-configured selectable set (with tags); empty means unconstrained +// (Inventory lists projects live). +type Provider struct { + binary string + allowlist *cloud.CommandAllowlist + projects []Project +} + +// New constructs the gcp provider, resolving gcloud to an absolute path once via +// exec.LookPath so a poisoned PATH cannot redirect the binary at run time. A +// PATH with relative entries makes LookPath return a relative path (flagged with +// exec.ErrDot); the path is made absolute so a later subprocess env/PATH change +// cannot reinterpret it against a different working directory. +func New(opts ...Options) (*Provider, error) { + bin, err := exec.LookPath("gcloud") + if err != nil && !errors.Is(err, exec.ErrDot) { + return nil, fmt.Errorf("gcp: resolve gcloud binary: %w", err) + } + abs, err := filepath.Abs(bin) + if err != nil { + return nil, fmt.Errorf("gcp: resolve gcloud binary to absolute path: %w", err) + } + return newWithBinary(abs, opts...) +} + +// newWithBinary builds the provider against an already-resolved binary path. It +// is the seam tests inject a fixed path through, bypassing exec.LookPath. At most +// one Options is honored. +func newWithBinary(binary string, opts ...Options) (*Provider, error) { + var list cloud.CommandAllowlist + if err := json.Unmarshal(defaultCommandsJSON, &list); err != nil { + return nil, fmt.Errorf("gcp: parse embedded default_commands.json: %w", err) + } + var o Options + if len(opts) > 0 { + o = opts[0] + } + return &Provider{binary: binary, allowlist: &list, projects: o.Projects}, nil +} + +// Name reports the provider identifier. +func (p *Provider) Name() string { return "gcp" } + +// Binary is the resolved absolute path to gcloud. +func (p *Provider) Binary() string { return p.binary } + +// DefaultAllowlist is the embedded read-only command allowlist. +func (p *Provider) DefaultAllowlist() *cloud.CommandAllowlist { return p.allowlist } + +// DenyFloorAdditions contributes gcp-specific subcommands that read credentials, +// shell into instances, exfiltrate or read object contents, decrypt, or mutate by +// side effect, on top of the base floor. The base floor prefix-matches top-level +// tokens, so it never reaches the nested storage/kms verbs; each is listed by its +// full token-wise path. `gcloud secrets versions access` is already covered by +// the base `secrets` prefix. Metadata reads (`storage ls`, `storage buckets +// describe`, `kms keys list`) are deliberately absent: the floor targets object +// CONTENTS and decryption, not listing or describing. +func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { + return cloud.DenyFloor{ + Subcommands: []string{ + "compute ssh", + "compute scp", + "compute reset-windows-password", + "functions call", + "storage cp", + "storage mv", + "storage rsync", + "storage cat", + "kms decrypt", + }, + } +} + +// ConfiguredTargets is the deployment-configured project set surfaced as the +// agent's selectable targets, with each project's tags. Empty when the source +// configured no projects (the unconstrained form), so the server falls back to +// the live inventory instead. +func (p *Provider) ConfiguredTargets() []cloud.Target { + out := make([]cloud.Target, 0, len(p.projects)) + for _, pr := range p.projects { + out = append(out, cloud.Target{ID: pr.ID, Name: pr.ID, Tags: pr.Tags}) + } + return out +} + +// ActiveTargetEnv pins gcloud to the active project via CLOUDSDK_CORE_PROJECT, +// the default project for every command that takes one. One impersonated +// identity spans the allowlisted projects, so switching changes only the +// project, never the identity. +func (p *Provider) ActiveTargetEnv(id string) []string { + return []string{"CLOUDSDK_CORE_PROJECT=" + id} +} + +// ExpectedIdentity reports no per-target identity: one impersonated service +// account spans every allowlisted project, so switching projects never changes +// the identity. The server validates against the session's pinned identity. +func (p *Provider) ExpectedIdentity(string) (string, bool) { return "", false } + +// EnvPassthrough names the gcloud env vars the subprocess needs: the pinned +// impersonation target plus the config and active-project locations. PATH and +// HOME are forwarded by the harness base set, so they are absent here. +func (p *Provider) EnvPassthrough() []string { + return []string{ + EnvImpersonate, + "CLOUDSDK_CONFIG", + "CLOUDSDK_CORE_PROJECT", + } +} diff --git a/pkg/mcp/cloud/providers/gcp/provider_test.go b/pkg/mcp/cloud/providers/gcp/provider_test.go new file mode 100644 index 00000000..2544384b --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/provider_test.go @@ -0,0 +1,151 @@ +package gcp + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewResolvesBinaryAndName(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + assert.Equal(t, "gcp", p.Name()) + assert.Equal(t, "/usr/bin/gcloud", p.Binary()) +} + +// TestNewResolvesBinaryToAbsolutePath proves New stores an absolute binary path +// even when PATH resolution would yield a relative one, so a later subprocess +// env/PATH change cannot redirect what executes. The provider's CLI is dropped +// into a temp dir reachable through a relative PATH entry; the resolved binary +// must come back absolute. +func TestNewResolvesBinaryToAbsolutePath(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "gcloud") + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755)) + + cwd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + require.NoError(t, os.Chdir(dir)) + + // "." is a relative PATH entry; exec.LookPath("gcloud") resolves to "gcloud" + // (relative) under it. + t.Setenv("PATH", ".") + + p, err := New() + require.NoError(t, err) + assert.True(t, filepath.IsAbs(p.Binary()), + "New must store an absolute binary path, got %q", p.Binary()) +} + +func TestDefaultAllowlistLoadsEmbeddedJSON(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + allow := p.DefaultAllowlist() + require.NotNil(t, allow) + assert.NotEmpty(t, allow.Commands, "embedded default_commands.json should ship read-only commands") +} + +func TestDefaultAllowlistIncludesProjectsList(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + assert.True(t, p.DefaultAllowlist().Allows([]string{"projects", "list", "--format=json"}), + "Inventory needs `projects list` on the allowlist") +} + +func TestDefaultAllowlistCoversInvestigativeAxes(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + allow := p.DefaultAllowlist() + // One representative read-only command per investigative axis. Exact-match + // allowlist, so each is the complete invariant verb chain. + axes := [][]string{ + {"projects", "list"}, // inventory + {"compute", "firewall-rules", "list"}, // reachability + {"projects", "get-iam-policy"}, // permissions / IAM read + {"container", "clusters", "describe"}, // cluster / GKE describe + {"logging", "read"}, // logs read + {"logging", "logs", "list"}, // audit read + } + for _, argv := range axes { + assert.Truef(t, allow.Allows(argv), "expected %v on the allowlist", argv) + } +} + +func TestDenyFloorAdditionsCoverDangerousGCPSubcommands(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + floor := p.DenyFloorAdditions() + for _, want := range []string{ + "compute ssh", + "compute scp", + "functions call", + "compute reset-windows-password", + } { + assert.Containsf(t, floor.Subcommands, want, "expected %q on the gcp deny-floor additions", want) + } +} + +// TestDenyFloorDropsNestedExfilDecryptOverrides asserts that even a profile +// override that tries to allowlist a nested object-content / decrypt command is +// dropped by the GCP deny floor, while metadata-only reads under the same +// services stay allowable. (`gcloud secrets versions access` is already covered +// by the base `secrets` prefix and is not re-listed here.) +func TestDenyFloorDropsNestedExfilDecryptOverrides(t *testing.T) { + t.Parallel() + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + floored := [][]string{ + {"storage", "cp"}, + {"storage", "mv"}, + {"storage", "rsync"}, + {"storage", "cat"}, + {"kms", "decrypt"}, + } + // Metadata-only reads must remain allowable: the floor targets object + // CONTENTS and decryption, not listing or describing. + metadataOnly := [][]string{ + {"storage", "ls"}, + {"storage", "buckets", "describe"}, + {"kms", "keys", "list"}, + } + + override := allowlistJSON(t, append(append([][]string{}, floored...), metadataOnly...)) + loaded, err := cloud.LoadCommandAllowlist(override, p.DenyFloorAdditions()) + require.NoError(t, err) + + for _, argv := range floored { + assert.Falsef(t, loaded.Allows(argv), "override must not re-enable floored %v", argv) + } + for _, argv := range metadataOnly { + assert.Truef(t, loaded.Allows(argv), "metadata-only %v must stay allowable", argv) + } +} + +// allowlistJSON writes a command allowlist document with the given subcommand +// paths to a temp file and returns its path, the seam LoadCommandAllowlist reads +// a profile override through. +func allowlistJSON(t *testing.T, paths [][]string) string { + t.Helper() + var doc cloud.CommandAllowlist + for _, p := range paths { + doc.Commands = append(doc.Commands, cloud.Command{Path: strings.Join(p, " "), Description: "test"}) + } + b, err := json.Marshal(doc) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "commands.json") + require.NoError(t, os.WriteFile(path, b, 0o600)) + return path +} diff --git a/pkg/mcp/cloud/providers/probe.go b/pkg/mcp/cloud/providers/probe.go new file mode 100644 index 00000000..1892c2e4 --- /dev/null +++ b/pkg/mcp/cloud/providers/probe.go @@ -0,0 +1,176 @@ +package providers + +import ( + "context" + "os" + "strings" + "time" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" +) + +// probeTimeout bounds a single identity probe so a hung CLI (a stale SSO flow, +// a slow network, a wedged gcloud/aws) cannot block /api/connections or session +// preflight indefinitely — the "degrade, never block" contract. A normal whoami +// returns in 1-3s; 15s sits comfortably above that yet well below anything that +// would stall a request. On deadline the CLI exec is killed, the provider +// surfaces the context error, and the probe degrades to Valid:false with a hint +// rather than hanging. A package var, not a const, so tests can shorten it. +var probeTimeout = 15 * time.Second + +// baseEnvPassthrough is the minimal env every provider CLI needs regardless of +// cloud: PATH so the resolved binary can find its own dependencies, HOME so it +// can locate per-user config. It mirrors the launcher-side serve harness; the +// per-source credential vars are overlaid on top. +var baseEnvPassthrough = []string{"PATH", "HOME"} + +// Source is a neutral description of one cloud connection to probe: the provider +// name, the pinned identity, and the aws credential config. It carries exactly +// what ProbeSource needs without coupling this package to the launcher's profile +// type. +// +// For AWS, Alias, SourceProfile, and Accounts describe the account set; +// ProbeSource generates the per-account profiles and probes the default (first) +// account's generated profile — the panel reflects the source's default-target +// validity, and per-account live validity is enforced by session_status on +// switch. gcp ignores all three. +type Source struct { + Provider string + AssumedIdentity string + Alias string // aws: the generated profiles' namespace + SourceProfile string // aws: the operator's SSO base profile + Accounts []aws.Account + // ConfigTarget is the triagent-owned config the aws provider generates and + // the probe's aws CLI reads (via AWS_CONFIG_FILE); ConfigSource is the + // operator config copied into it. aws-only. + ConfigTarget string + ConfigSource string +} + +// ProbeSource constructs the source's provider and runs the read-only identity +// probe, threading the expected identity and the subprocess credential env +// explicitly so concurrent probes for different sources never share state. The +// expected identity is provider-specific: gcp validates the resolved caller +// against AssumedIdentity (the impersonated service account); aws probes the +// default (first) account's generated profile and validates against that +// account's role_arn — there is no source-level assumed identity for aws. It +// degrades, never blocks: a provider construction error (e.g. a missing CLI +// binary) returns an invalid status with the error as the hint, exactly like a +// failed probe. +func ProbeSource(ctx context.Context, src Source) cloud.IdentityStatus { + expected := expectedIdentity(src) + p, err := New(src.Provider, Options{ + AWSAlias: src.Alias, + AWSSourceProfile: src.SourceProfile, + AWSAccounts: src.Accounts, + AWSConfigTarget: src.ConfigTarget, + AWSConfigSource: src.ConfigSource, + }) + if err != nil { + return cloud.IdentityStatus{ + Provider: src.Provider, + AssumedIdentity: expected, + Valid: false, + Hint: err.Error(), + } + } + return probeProvider(ctx, p, expected, sourceEnvFor(p, src)) +} + +// expectedIdentity is the identity the launcher-side probe validates against: +// gcp's impersonated service account, or aws's default (first) account's +// role_arn (the account whose generated profile awsProbeProfile authenticates +// with), so the panel reflects the source's default target. +func expectedIdentity(src Source) string { + if src.Provider == "aws" && len(src.Accounts) > 0 { + return src.Accounts[0].RoleARN + } + return src.AssumedIdentity +} + +// probeProvider runs the identity probe for an already-constructed provider +// under a bounded timeout, so a hung CLI degrades to an invalid status instead +// of blocking the caller. The deadline cancels the CLI exec; cloud.Probe +// surfaces the resulting context error as a Valid:false status with a hint. +func probeProvider(ctx context.Context, p cloud.Provider, expected string, env []string) cloud.IdentityStatus { + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + st, _ := cloud.Probe(ctx, p, expected, env) + return st +} + +// passthroughLister is the slice of the cloud.Provider contract sourceEnvFor +// needs: which env names the provider's CLI carries from the parent process. +type passthroughLister interface { + EnvPassthrough() []string +} + +// sourceEnvFor builds the explicit subprocess env for one source: the base +// PATH/HOME plus the provider's declared config-dir passthrough names, carried +// from the launcher process env, with the per-source credential var overlaid. +// The launcher process itself does not hold the pinned credential env (that is +// injected only into the serve subprocess), so ProbeSource supplies it here +// rather than reading it from os.Environ. +func sourceEnvFor(p passthroughLister, src Source) []string { + keep := make(map[string]bool, len(baseEnvPassthrough)+len(p.EnvPassthrough())) + for _, name := range baseEnvPassthrough { + keep[name] = true + } + for _, name := range p.EnvPassthrough() { + keep[name] = true + } + + overlay := credentialEnv(src) + var env []string + for _, kv := range os.Environ() { + name, _, ok := strings.Cut(kv, "=") + if !ok || !keep[name] { + continue + } + if _, overridden := overlay[name]; overridden { + continue + } + env = append(env, kv) + } + for name, val := range overlay { + env = append(env, name+"="+val) + } + return env +} + +// credentialEnv is the per-provider credential the CLI authenticates with for +// the source: gcp impersonates the assumed identity directly; aws selects the +// assume-role profile. For a multi-account aws source the profile is the default +// (first) account's generated profile name — the same name aws.New wrote into +// ~/.aws/config — so the launcher-side probe reflects the source's default +// target. The env-name constants come from the provider packages, never raw +// literals. +func credentialEnv(src Source) map[string]string { + switch src.Provider { + case "gcp": + return map[string]string{gcp.EnvImpersonate: src.AssumedIdentity} + case "aws": + m := map[string]string{aws.EnvProfile: awsProbeProfile(src)} + // Point the probe's aws CLI at the generated target config so it finds + // the assume-role profile (we no longer write ~/.aws/config). + if src.ConfigTarget != "" { + m[cloud.EnvAWSConfigFile] = src.ConfigTarget + } + return m + default: + return nil + } +} + +// awsProbeProfile is the AWS_PROFILE the launcher-side probe authenticates with: +// the default (first) account's generated profile. An AWS source always carries +// at least one account, so the panel reflects that default target's validity. +func awsProbeProfile(src Source) string { + if len(src.Accounts) == 0 { + return "" + } + return aws.ProfileName(src.Alias, src.Accounts[0].ID) +} diff --git a/pkg/mcp/cloud/providers/probe_test.go b/pkg/mcp/cloud/providers/probe_test.go new file mode 100644 index 00000000..14cbddaa --- /dev/null +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -0,0 +1,149 @@ +package providers + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// blockingProvider's Identity blocks until the probe context is cancelled, then +// surfaces the context error the way a real provider does when its CLI is +// killed by the deadline. It lets the timeout be observed without a real sleep. +type blockingProvider struct{} + +func (blockingProvider) Name() string { return "gcp" } +func (blockingProvider) Binary() string { return "/bin/true" } +func (blockingProvider) DefaultAllowlist() *cloud.CommandAllowlist { return &cloud.CommandAllowlist{} } +func (blockingProvider) DenyFloorAdditions() cloud.DenyFloor { return cloud.DenyFloor{} } +func (blockingProvider) EnvPassthrough() []string { return nil } +func (blockingProvider) Inventory(context.Context, cloud.RunFunc) (cloud.Inventory, error) { + return cloud.Inventory{}, nil +} +func (blockingProvider) ConfiguredTargets() []cloud.Target { return nil } +func (blockingProvider) ActiveTargetEnv(string) []string { return nil } +func (blockingProvider) ExpectedIdentity(string) (string, bool) { return "", false } + +func (blockingProvider) Identity(ctx context.Context, _ cloud.RunFunc, _ string) (cloud.IdentityStatus, error) { + <-ctx.Done() + return cloud.IdentityStatus{}, ctx.Err() +} + +func TestProbeProviderBoundsHungCLI(t *testing.T) { + defer func(orig time.Duration) { probeTimeout = orig }(probeTimeout) + probeTimeout = 50 * time.Millisecond + + done := make(chan cloud.IdentityStatus, 1) + go func() { + done <- probeProvider(context.Background(), blockingProvider{}, "", nil) + }() + + select { + case st := <-done: + assert.False(t, st.Valid, "a hung probe must degrade to an invalid status, not block") + assert.Equal(t, "gcp", st.Provider) + assert.NotEmpty(t, st.Hint, "the deadline error must surface as a hint") + case <-time.After(2 * time.Second): + t.Fatal("probeProvider did not return: the probe timeout was not propagated") + } +} + +// TestProbeSourceDoesNotMutateProcessEnv pins the core guarantee of the +// explicit-threading refactor: ProbeSource builds the credential env for the +// subprocess without writing it into the launcher's own process env. A sentinel +// and the per-provider credential names must read identically before and after. +func TestProbeSourceDoesNotMutateProcessEnv(t *testing.T) { + t.Setenv("TRIAGENT_PROBE_SENTINEL", "untouched") + t.Setenv(aws.EnvProfile, "operator-base") + t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "config")) + if err := os.Unsetenv(gcp.EnvImpersonate); err != nil { + require.NoError(t, err) + } + + for _, src := range []Source{ + {Provider: "gcp", AssumedIdentity: "ro-sa@proj.iam.gserviceaccount.com"}, + {Provider: "aws", Alias: "probe-aws", SourceProfile: "triage-ro", Accounts: []aws.Account{{ID: "111122223333", RoleARN: "arn:aws:iam::111122223333:role/triage-ro"}}}, + } { + _ = ProbeSource(context.Background(), src) + } + + assert.Equal(t, "untouched", os.Getenv("TRIAGENT_PROBE_SENTINEL"), + "ProbeSource must not write to the process env") + assert.Equal(t, "operator-base", os.Getenv(aws.EnvProfile), + "ProbeSource must not pin AWS_PROFILE in the process env") + _, set := os.LookupEnv(gcp.EnvImpersonate) + assert.False(t, set, "ProbeSource must not pin the gcp impersonation env in the process env") +} + +func TestProbeSourceUnknownProviderDegrades(t *testing.T) { + st := ProbeSource(context.Background(), Source{Provider: "azure"}) + assert.False(t, st.Valid) + assert.Equal(t, "azure", st.Provider) + assert.NotEmpty(t, st.Hint) +} + +// TestProbeSourceConstructionFailureKeepsPinnedIdentity proves a provider +// construction failure (here an unknown provider, which never reaches New's CLI +// lookup but exercises the same construction-error path) still reports the +// pinned identity, so preflight and connections name the degraded source's +// identity the operator must fix instead of an empty one. +func TestProbeSourceConstructionFailureKeepsPinnedIdentity(t *testing.T) { + const pinned = "arn:aws:iam::111122223333:role/triage-ro" + st := ProbeSource(context.Background(), Source{Provider: "azure", AssumedIdentity: pinned}) + assert.False(t, st.Valid) + assert.Equal(t, "azure", st.Provider) + assert.Equal(t, pinned, st.AssumedIdentity, + "a construction failure must still carry the pinned identity") + assert.NotEmpty(t, st.Hint) +} + +// TestCredentialEnvAWSTargetsDefaultProfile proves the launcher-side probe for +// an aws source pins AWS_PROFILE to the default (first) account's generated +// profile name. The panel shows that default target's validity; per-account live +// validity is enforced by session_status on switch. +func TestCredentialEnvAWSTargetsDefaultProfile(t *testing.T) { + env := credentialEnv(Source{ + Provider: "aws", + Alias: "prod-aws", + SourceProfile: "sso-admin", + Accounts: []aws.Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}, + {ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r"}, + }, + }) + assert.Equal(t, "triagent-cloud-prod-aws-111111111111", env[aws.EnvProfile], + "the probe must target the default account's generated profile") +} + +// fakePassthroughProvider exposes a fixed EnvPassthrough so sourceEnv's +// carry-and-overlay behaviour can be asserted without a real cloud CLI. +type fakePassthroughProvider struct{ passthrough []string } + +func (p *fakePassthroughProvider) EnvPassthrough() []string { return p.passthrough } + +func TestSourceEnvOverlaysCredentialOverProcessEnv(t *testing.T) { + t.Setenv("PATH", "/usr/bin") + t.Setenv("CLOUDSDK_CONFIG", "/home/op/.config/gcloud") + t.Setenv("TRIAGENT_PROBE_LEAK", "should-not-cross") + t.Setenv(gcp.EnvImpersonate, "operator-leaked@proj.iam.gserviceaccount.com") + + p := &fakePassthroughProvider{passthrough: []string{gcp.EnvImpersonate, "CLOUDSDK_CONFIG"}} + env := sourceEnvFor(p, Source{Provider: "gcp", AssumedIdentity: "ro-sa@proj.iam.gserviceaccount.com"}) + + assert.Contains(t, env, "PATH=/usr/bin", "base PATH is carried from the process env") + assert.Contains(t, env, "CLOUDSDK_CONFIG=/home/op/.config/gcloud", "declared config dir is carried") + assert.Contains(t, env, gcp.EnvImpersonate+"=ro-sa@proj.iam.gserviceaccount.com", + "the source credential overrides the process-env value") + assert.NotContains(t, env, gcp.EnvImpersonate+"=operator-leaked@proj.iam.gserviceaccount.com", + "the operator's ambient impersonation value must not survive the overlay") + for _, kv := range env { + assert.NotContains(t, kv, "TRIAGENT_PROBE_LEAK", "undeclared process env must not cross the boundary") + } +} diff --git a/pkg/mcp/cloud/providers/registry.go b/pkg/mcp/cloud/providers/registry.go new file mode 100644 index 00000000..63c3a8d5 --- /dev/null +++ b/pkg/mcp/cloud/providers/registry.go @@ -0,0 +1,62 @@ +// Package providers is the single construction site for a cloud.Provider. It +// imports the concrete gcp and aws packages and resolves a provider name to a +// constructed value, so every consumer — the triagent-mcp serve arm, the +// session preflight, and the connections panel — obtains a provider the same +// way. This mirrors how the launcher builds an auth.Provider from +// pkg/auth/teleport and pkg/auth/kubeconfig: a neutral package that imports the +// implementations the cloud package itself cannot import without a cycle. +package providers + +import ( + "fmt" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" +) + +// Options carries the per-provider target config from the profile's cloud +// source. aws: the source alias (the generated profiles' namespace), the +// operator's SSO source_profile, and the account set. gcp: the configured +// project set. Each provider ignores the other's fields. The zero value is the +// unconstrained form (no configured targets), so a probe that only needs the +// identity can call New(name) unchanged. +type Options struct { + AWSAlias string + AWSSourceProfile string + AWSAccounts []aws.Account + GCPProjects []gcp.Project + // AWSConfigTarget is the triagent-owned config the aws provider generates + // (a copy of the operator config plus the managed blocks); AWSConfigSource is + // the operator config it copies from. aws-only. + AWSConfigTarget string + AWSConfigSource string +} + +// New constructs the cloud.Provider for the named provider ("gcp" | "aws"), +// threading the aws multi-account config through when present. The concrete +// New() resolves the provider's CLI binary to an absolute path and, for an aws +// source with accounts, generates the per-account assume-role profiles; a +// missing binary surfaces as a construction error, which the launcher degrades +// to an unavailable cloud source rather than a fatal failure. An unknown name is +// named in the error. At most one Options is honored. +func New(name string, opts ...Options) (cloud.Provider, error) { + var o Options + if len(opts) > 0 { + o = opts[0] + } + switch name { + case "gcp": + return gcp.New(gcp.Options{Projects: o.GCPProjects}) + case "aws": + return aws.New(aws.Options{ + Alias: o.AWSAlias, + SourceProfile: o.AWSSourceProfile, + Accounts: o.AWSAccounts, + ConfigTargetPath: o.AWSConfigTarget, + ConfigSourcePath: o.AWSConfigSource, + }) + default: + return nil, fmt.Errorf("unknown cloud provider %q (want gcp or aws)", name) + } +} diff --git a/pkg/mcp/cloud/providers/registry_test.go b/pkg/mcp/cloud/providers/registry_test.go new file mode 100644 index 00000000..9b041a29 --- /dev/null +++ b/pkg/mcp/cloud/providers/registry_test.go @@ -0,0 +1,74 @@ +package providers + +import ( + "path/filepath" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew_KnownProviders(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + want string + }{ + {"gcp", "gcp"}, + {"aws", "aws"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + p, err := New(tc.name) + // The provider's New() resolves its CLI binary via exec.LookPath, + // which may be absent in CI. A missing binary is a construction + // error, not an unknown-provider error — assert on whichever + // outcome the environment produced, but never a nil provider with + // a nil error. + if err != nil { + assert.Nil(t, p, "a construction error must not also return a provider") + return + } + require.NotNil(t, p) + assert.Equal(t, tc.want, p.Name()) + }) + } +} + +func TestNew_UnknownProviderErrors(t *testing.T) { + t.Parallel() + p, err := New("azure") + require.Error(t, err) + assert.Nil(t, p) + assert.Contains(t, err.Error(), "azure") +} + +// TestNewAWSWithAccounts proves the factory threads the aws multi-account config +// through to the provider: ConfiguredTargets surfaces the accounts and the +// active-target env names the generated profile. Construction generates profiles +// into a temp config so it does not touch the developer's ~/.aws/config; a +// missing aws binary in CI degrades to a construction error, which the test +// tolerates the same way TestNew_KnownProviders does. +func TestNewAWSWithAccounts(t *testing.T) { + t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "config")) + p, err := New("aws", Options{ + AWSAlias: "prod-aws", + AWSSourceProfile: "sso-admin", + AWSAccounts: []aws.Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}, + {ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r"}, + }, + }) + if err != nil { + assert.Nil(t, p, "a construction error must not also return a provider") + return + } + require.NotNil(t, p) + targets := p.ConfiguredTargets() + require.Len(t, targets, 2) + assert.Equal(t, "111111111111", targets[0].ID) + assert.Equal(t, []string{"AWS_PROFILE=triagent-cloud-prod-aws-111111111111"}, p.ActiveTargetEnv("111111111111")) +} diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go new file mode 100644 index 00000000..57f465c0 --- /dev/null +++ b/pkg/mcp/cloud/server.go @@ -0,0 +1,269 @@ +package cloud + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sourcehawk/triagent/pkg/mcp/telemetry" +) + +// baseEnvPassthrough is the minimal env every provider CLI needs regardless of +// cloud: PATH so the resolved binary can find its own dependencies, HOME so it +// can locate per-user config. Providers add their credential/impersonation +// names via Provider.EnvPassthrough. +var baseEnvPassthrough = []string{"PATH", "HOME"} + +// Options configures the cloud-context MCP server. +type Options struct { + // Provider is the cloud-specific backend (gcp or aws), injected behind the + // Provider interface. Required; New errors when nil. + Provider Provider + // AllowlistPath optionally overrides the provider's embedded default command + // allowlist. Empty means use the provider default. The launcher points this + // at the profile-configured override via TRIAGENT_CLOUD_ALLOWLIST_PATH. + AllowlistPath string + // Scope is the set of projects/accounts/regions any run_cli argv may target. + // Argv referencing a target outside the scope is rejected before exec. The + // launcher fills it from TRIAGENT_CLOUD_SCOPE. + Scope ScopeAllowlist + // ExpectedIdentity is the identity the launcher pinned for this session, + // threaded into the identity probe so it validates the resolved identity + // against it. The launcher fills it from TRIAGENT_CLOUD_EXPECTED_IDENTITY. + ExpectedIdentity string +} + +// Server holds the configured cloud-context MCP server. +type Server struct { + impl *mcp.Server + provider Provider + allowlist *CommandAllowlist + scope ScopeAllowlist + expectedIdentity string + // activeTarget is the project (gcp) or account (aws) subsequent run_cli + // commands run against, chosen via set_active_target from selectableTargets. + // Empty means none chosen yet; subprocessEnv injects the provider's target + // env only when set. + activeTarget string +} + +// New constructs a cloud-context MCP server. Provider is required. The command +// allowlist loads from Options.AllowlistPath (or the provider default when +// empty), always filtered through the base deny floor plus the provider's +// additions, so a too-broad override can never re-enable a floored command. +func New(opts Options) (*Server, error) { + if opts.Provider == nil { + return nil, fmt.Errorf("cloud: Provider is required") + } + allow, err := loadAllowlist(opts.AllowlistPath, opts.Provider) + if err != nil { + return nil, fmt.Errorf("cloud: load command allowlist: %w", err) + } + impl := mcp.NewServer(&mcp.Implementation{ + Name: "triagent-mcp-cloud", + Version: "0.1.0", + }, nil) + s := &Server{ + impl: impl, + provider: opts.Provider, + allowlist: allow, + scope: opts.Scope, + expectedIdentity: opts.ExpectedIdentity, + } + // A single selectable target is the active target from session start + // (today's behavior); with several, the agent must choose via + // set_active_target before run_cli will run. + if sel := s.selectableTargets(context.Background()); len(sel) == 1 { + s.activeTarget = sel[0].ID + } + s.registerOn(impl) + return s, nil +} + +// selectableTargets returns the set the agent may choose from: the provider's +// configured targets (aws accounts, gcp projects) when the deployment configured +// any, else (unconstrained) the live inventory scopes. +func (s *Server) selectableTargets(ctx context.Context) []Target { + if t := s.provider.ConfiguredTargets(); len(t) > 0 { + return t + } + inv, err := s.provider.Inventory(ctx, s.runValidated) + if err != nil { + return nil + } + out := make([]Target, 0, len(inv.Scopes)) + for _, sc := range inv.Scopes { + out = append(out, Target(sc)) + } + return out +} + +// setActive validates id against the selectable set and pins it as the active +// target. An id outside the set is rejected, so the agent can never name a +// target the deployment did not configure. +func (s *Server) setActive(id string) error { + for _, t := range s.selectableTargets(context.Background()) { + if t.ID == id { + s.activeTarget = id + return nil + } + } + return fmt.Errorf("target %q is not in the configured set", id) +} + +// loadAllowlist resolves the command allowlist for a provider: the override path +// when given, else the provider's embedded default, always filtered through the +// base deny floor plus the provider's deny-floor additions. +func loadAllowlist(path string, p Provider) (*CommandAllowlist, error) { + if path != "" { + return LoadCommandAllowlist(path, p.DenyFloorAdditions()) + } + // Filter the provider's in-memory default through the floor the same way a + // loaded file would be, so the default can never advertise a floored command. + return filterAllowlist(p.DefaultAllowlist(), p.DenyFloorAdditions()), nil +} + +// Run serves MCP requests over stdio until the client disconnects or ctx is +// cancelled. +func (s *Server) Run(ctx context.Context) error { + return s.impl.Run(ctx, &mcp.StdioTransport{}) +} + +// run is the harness exec core bound to this server's provider binary, scope, +// and allowlist. Tools exec only through this RunFunc, never directly: it gates +// on an active target being chosen when several are selectable, then validates +// argv before handing it to the no-shell exec core. +func (s *Server) run(ctx context.Context, argv []string) (CLIResult, error) { + if s.activeTarget == "" && len(s.selectableTargets(ctx)) > 1 { + return CLIResult{}, errNoActiveTarget + } + return s.runValidated(ctx, argv) +} + +// runValidated is the exec core without the active-target gate: it validates +// argv against the allowlist and scope, then execs under the subprocess env. +// selectableTargets derives the target set through this path so deriving the +// set never re-enters the active-target check (which itself consults +// selectableTargets) — inventory shelled during derivation cannot recurse. +func (s *Server) runValidated(ctx context.Context, argv []string) (CLIResult, error) { + if err := validateArgv(argv, s.allowlist, s.scope); err != nil { + return CLIResult{}, err + } + return execCLI(ctx, s.provider.Binary(), argv, s.subprocessEnv(), defaultOutputLimit) +} + +// errNoActiveTarget is returned by run when several targets are selectable but +// none is active, so a command never runs against an unintended default. It is +// surfaced to the agent as an actionable run_cli tool error. +var errNoActiveTarget = errors.New("no active target; call set_active_target to choose one") + +// expectedIdentityForActive is the identity the probe validates the session +// against: the active target's own identity when the provider pins it per-target +// (aws: the account's role ARN), else the session's pinned identity (gcp, where +// one impersonated service account spans every project). This is what lets +// session_status report Valid for any selected account, not just the default. +func (s *Server) expectedIdentityForActive() string { + if s.activeTarget != "" { + if exp, ok := s.provider.ExpectedIdentity(s.activeTarget); ok { + return exp + } + } + return s.expectedIdentity +} + +// subprocessEnv builds the explicit, minimal environment for a provider CLI +// invocation: only the base names plus the provider's declared passthrough +// names, read from the launcher-controlled process env. Everything else is +// dropped, so the launcher's ambient secrets never reach the CLI. +// +// The active-target env (gcp CLOUDSDK_CORE_PROJECT, aws AWS_PROFILE) overrides +// any ambient value for the same name carried through passthrough: the ambient +// entry is dropped before the MCP-controlled value is appended, so a duplicate +// can never let the CLI resolve to the ambient target instead of the +// set_active_target choice. +func (s *Server) subprocessEnv() []string { + env := minimalEnv(s.provider.EnvPassthrough()) + if s.activeTarget != "" { + active := s.provider.ActiveTargetEnv(s.activeTarget) + env = append(dropEnvNames(env, envNames(active)), active...) + } + return env +} + +// envNames returns the variable names ("NAME" from "NAME=value") of env entries. +func envNames(env []string) []string { + names := make([]string, 0, len(env)) + for _, kv := range env { + if name, _, ok := strings.Cut(kv, "="); ok { + names = append(names, name) + } + } + return names +} + +// dropEnvNames returns env without any entry whose variable name is in names. +func dropEnvNames(env, names []string) []string { + drop := make(map[string]bool, len(names)) + for _, n := range names { + drop[n] = true + } + out := env[:0] + for _, kv := range env { + if name, _, ok := strings.Cut(kv, "="); ok && drop[name] { + continue + } + out = append(out, kv) + } + return out +} + +// minimalEnv returns the subprocess environment built from os.Environ() filtered +// to the base passthrough names plus the provider-declared ones — everything +// else (the launcher's ambient secrets) is dropped. Both the run_cli harness and +// the identity probe build their subprocess env here so neither can leak the +// parent environment. +func minimalEnv(passthrough []string) []string { + keep := make(map[string]bool, len(baseEnvPassthrough)+len(passthrough)) + for _, name := range baseEnvPassthrough { + keep[name] = true + } + for _, name := range passthrough { + keep[name] = true + } + var env []string + for _, kv := range os.Environ() { + name, _, ok := strings.Cut(kv, "=") + if ok && keep[name] { + env = append(env, kv) + } + } + return env +} + +// registerOn wires the cloud tools onto impl. Called from New and from the wire +// test inside the package. Registration order mirrors ToolSpecs(). +func (s *Server) registerOn(impl *mcp.Server) { + mcp.AddTool(impl, &mcp.Tool{ + Name: "list_inventory", + Description: descListInventory, + }, telemetry.Wrap("list_inventory", s.listInventory)) + mcp.AddTool(impl, &mcp.Tool{ + Name: "session_status", + Description: descSessionStatus, + }, telemetry.Wrap("session_status", s.sessionStatus)) + mcp.AddTool(impl, &mcp.Tool{ + Name: "set_active_target", + Description: descSetActiveTarget, + }, telemetry.Wrap("set_active_target", s.setActiveTarget)) + mcp.AddTool(impl, &mcp.Tool{ + Name: "run_cli", + Description: descRunCLI, + }, telemetry.Wrap("run_cli", s.runCLI)) + mcp.AddTool(impl, &mcp.Tool{ + Name: "list_allowed_commands", + Description: descListAllowedCommands, + }, telemetry.Wrap("list_allowed_commands", s.listAllowedCommands)) +} diff --git a/pkg/mcp/cloud/server_test.go b/pkg/mcp/cloud/server_test.go new file mode 100644 index 00000000..0e3e6f86 --- /dev/null +++ b/pkg/mcp/cloud/server_test.go @@ -0,0 +1,211 @@ +package cloud + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFakeProviderSatisfiesActiveTargetContract(t *testing.T) { + t.Parallel() + var p Provider = &fakeProvider{} + require.NotNil(t, p) + // compile-time: the interface now includes ActiveTargetEnv + ConfiguredTargets +} + +func TestNewRequiresProvider(t *testing.T) { + t.Parallel() + _, err := New(Options{}) + require.Error(t, err, "expected error when Provider is nil") + _, err = New(Options{Provider: &fakeProvider{}}) + require.NoError(t, err) +} + +func TestSelectableTargetsPrefersConfigured(t *testing.T) { + t.Parallel() + p := &fakeProvider{targets: []Target{{ID: "acct-1", Name: "one"}}} + s := newTestServer(t, p) + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "acct-1", Name: "one"}}, got) +} + +func TestSelectableTargetsCarriesTags(t *testing.T) { + t.Parallel() + p := &fakeProvider{targets: []Target{{ID: "acct-1", Name: "one", Tags: []string{"prod", "payments"}}}} + s := newTestServer(t, p) + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "acct-1", Name: "one", Tags: []string{"prod", "payments"}}}, got) +} + +func TestSelectableTargetsFallsBackToInventory(t *testing.T) { + t.Parallel() + p := &fakeProvider{inventory: Inventory{Scopes: []Scope{{ID: "p1", Name: "Project One"}}}} + s := newTestServer(t, p) + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "p1", Name: "Project One"}}, got) +} + +// liveInventoryProvider mirrors the real gcp/aws providers: its Inventory shells +// through the injected RunFunc rather than returning a canned set. The +// in-package fakeProvider ignores run, so only this double exercises the path +// where deriving the selectable set executes the CLI — the path that recursed +// before selectableTargets was given a run core without the active-target gate. +type liveInventoryProvider struct { + fakeProvider + scopes []Scope +} + +func (p *liveInventoryProvider) Inventory(ctx context.Context, run RunFunc) (Inventory, error) { + if _, err := run(ctx, []string{"echo", "inventory"}); err != nil { + return Inventory{}, err + } + return Inventory{Scopes: p.scopes}, nil +} + +// TestSelectableTargetsInventoryDoesNotRecurse pins that deriving the selectable +// set from live inventory does not re-enter the active-target gate. With no +// configured targets and no scope, selectableTargets shells inventory; that +// inventory run must not consult selectableTargets again, or New stack-overflows +// for unconstrained GCP and single-account AWS sources. +func TestSelectableTargetsInventoryDoesNotRecurse(t *testing.T) { + t.Parallel() + p := &liveInventoryProvider{ + fakeProvider: fakeProvider{ + binary: "/bin/echo", + allowlist: &CommandAllowlist{Commands: []Command{{Path: "echo"}}}, + }, + scopes: []Scope{{ID: "p1", Name: "one"}, {ID: "p2", Name: "two"}}, + } + s := newTestServer(t, p) + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "p1", Name: "one"}, {ID: "p2", Name: "two"}}, got) +} + +// TestListInventoryDoesNotGateOnActiveTarget pins that list_inventory works +// before a target is chosen: the agent needs inventory to know which target it +// can select, so inventory must use the ungated exec path, not s.run (whose +// active-target gate would fail with "call set_active_target" for a multi-target +// GCP source with nothing active yet). +func TestListInventoryDoesNotGateOnActiveTarget(t *testing.T) { + t.Parallel() + p := &liveInventoryProvider{ + fakeProvider: fakeProvider{ + binary: "/bin/echo", + allowlist: &CommandAllowlist{Commands: []Command{{Path: "echo"}}}, + }, + scopes: []Scope{{ID: "p1", Name: "one"}, {ID: "p2", Name: "two"}}, + } + s := newTestServer(t, p) + res, out, err := s.listInventory(context.Background(), nil, ListInventoryInput{}) + require.NoError(t, err) + if res != nil { + t.Fatalf("list_inventory must not error before an active target is chosen: %s", errText(res)) + } + assert.Len(t, out.Scopes, 2) +} + +// TestSubprocessEnvActiveTargetWinsOverAmbient pins that the active-target env +// the MCP controls overrides an ambient value for the same variable carried +// through passthrough — no duplicate entry an exec reader could resolve to the +// ambient target instead of the set_active_target choice. +func TestSubprocessEnvActiveTargetWinsOverAmbient(t *testing.T) { + t.Setenv("FAKE_TARGET", "ambient-leak") + p := &fakeProvider{targets: []Target{{ID: "chosen"}}, envPassthrough: []string{"FAKE_TARGET"}} + s := newTestServer(t, p) + require.NoError(t, s.setActive("chosen")) + + env := s.subprocessEnv() + + assert.Contains(t, env, "FAKE_TARGET=chosen", "the active-target value must be present") + assert.NotContains(t, env, "FAKE_TARGET=ambient-leak", + "the ambient value must not survive alongside the active-target value") + var n int + for _, kv := range env { + if name, _, _ := strings.Cut(kv, "="); name == "FAKE_TARGET" { + n++ + } + } + assert.Equal(t, 1, n, "FAKE_TARGET must appear exactly once") +} + +func TestSetActiveTargetRejectsOutOfSet(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}}) + require.Error(t, s.setActive("acct-9")) + require.NoError(t, s.setActive("acct-1")) + assert.Equal(t, "acct-1", s.activeTarget) +} + +// TestExpectedIdentityForActiveUsesPerTargetIdentity pins that the probe +// validates against the active target's own identity when the provider pins one +// per target (aws: the account's role ARN), so session_status reports Valid for +// any selected account — not only the source default. With no active target, or +// a provider that pins no per-target identity, it falls back to the session's +// pinned identity. +func TestExpectedIdentityForActiveUsesPerTargetIdentity(t *testing.T) { + t.Parallel() + p := &fakeProvider{ + targets: []Target{{ID: "a"}, {ID: "b"}}, + expectedFor: map[string]string{"a": "role-a", "b": "role-b"}, + } + s := newTestServer(t, p, func(o *Options) { o.ExpectedIdentity = "source-pin" }) + + assert.Equal(t, "source-pin", s.expectedIdentityForActive(), + "with no active target, the session's pinned identity is used") + + require.NoError(t, s.setActive("b")) + assert.Equal(t, "role-b", s.expectedIdentityForActive(), + "the active account's own identity is validated, not the source default") +} + +func TestExpectedIdentityForActiveFallsBackWhenProviderHasNone(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "only"}}}, + func(o *Options) { o.ExpectedIdentity = "source-pin" }) + // fakeProvider has no expectedFor entry; the provider pins no per-target + // identity (the gcp case), so the session's pinned identity is used. + assert.Equal(t, "source-pin", s.expectedIdentityForActive()) +} + +func TestSubprocessEnvIncludesActiveTarget(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}}) + require.NoError(t, s.setActive("acct-1")) + assert.Contains(t, s.subprocessEnv(), "FAKE_TARGET=acct-1") +} + +func TestSingleTargetIsDefaultActive(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "only"}}}) + assert.Equal(t, "only", s.activeTarget) +} + +func TestMultiTargetHasNoDefault(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "a"}, {ID: "b"}}}) + assert.Equal(t, "", s.activeTarget) +} + +// TestSubprocessEnvDropsParentSecretsKeepsPassthrough exercises the env the +// server actually builds for run_cli — the path the real harness takes, which +// the isolated execCLI test cannot cover. A parent-env canary must be dropped +// while a declared passthrough var survives, so ambient launcher secrets never +// reach the provider CLI. +func TestSubprocessEnvDropsParentSecretsKeepsPassthrough(t *testing.T) { + t.Setenv("TRIAGENT_CLOUD_LEAK_CANARY", "should-not-appear") + t.Setenv("CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT", "ro-sa@proj.iam.gserviceaccount.com") + p := &fakeProvider{ + envPassthrough: []string{"CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"}, + } + srv := newTestServer(t, p) + + env := srv.subprocessEnv() + + assert.NotContains(t, env, "TRIAGENT_CLOUD_LEAK_CANARY=should-not-appear", + "parent-env secret must be dropped from the subprocess env") + assert.Contains(t, env, "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=ro-sa@proj.iam.gserviceaccount.com", + "declared passthrough var must be forwarded") +} diff --git a/pkg/mcp/cloud/specs.go b/pkg/mcp/cloud/specs.go new file mode 100644 index 00000000..c567ec3f --- /dev/null +++ b/pkg/mcp/cloud/specs.go @@ -0,0 +1,42 @@ +package cloud + +import "github.com/sourcehawk/triagent/pkg/mcp/toolspec" + +// ToolSpecs returns the cloud server's tool catalog with each tool's input shape +// introspected from its Go struct (and its jsonschema tags). +// +// Order mirrors the registration order in server.go's registerOn(). +func ToolSpecs() []toolspec.ToolSpec { + return []toolspec.ToolSpec{ + { + Server: "triagent-cloud", + Name: "list_inventory", + Description: descListInventory, + Inputs: toolspec.FromStruct(ListInventoryInput{}), + }, + { + Server: "triagent-cloud", + Name: "session_status", + Description: descSessionStatus, + Inputs: toolspec.FromStruct(SessionStatusInput{}), + }, + { + Server: "triagent-cloud", + Name: "set_active_target", + Description: descSetActiveTarget, + Inputs: toolspec.FromStruct(SetActiveTargetInput{}), + }, + { + Server: "triagent-cloud", + Name: "run_cli", + Description: descRunCLI, + Inputs: toolspec.FromStruct(RunCLIInput{}), + }, + { + Server: "triagent-cloud", + Name: "list_allowed_commands", + Description: descListAllowedCommands, + Inputs: toolspec.FromStruct(ListAllowedCommandsInput{}), + }, + } +} diff --git a/pkg/mcp/cloud/tools_cli.go b/pkg/mcp/cloud/tools_cli.go new file mode 100644 index 00000000..0eab0881 --- /dev/null +++ b/pkg/mcp/cloud/tools_cli.go @@ -0,0 +1,57 @@ +package cloud + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const descRunCLI = "Run one read-only provider CLI command (gcloud/aws). Supply the argument tokens as an array, never a single string — there is no shell. Only allowlisted subcommands run; identity flags, credential-reading subcommands, and out-of-scope targets are rejected. See list_allowed_commands for what is permitted." + +const descListAllowedCommands = "List the provider CLI subcommands run_cli permits, with the investigative axis each serves. This is exactly what run_cli enforces, so what is advertised is what is allowed." + +// RunCLIInput is the input schema for run_cli. Argv is a typed array of argument +// tokens, never a single command string: the harness never tokenizes, so there +// is no in-house splitter to fool and shell metacharacters are inert. +type RunCLIInput struct { + Argv []string `json:"argv" jsonschema:"The provider CLI argument tokens as an array (for example [\"ec2\",\"describe-instances\",\"--region\",\"eu-west-1\"]). Do not include the binary name or pass a single string. Do not pass identity or target-selecting flags (--project, --account, --profile); they are rejected, and the active target is the default."` +} + +// RunCLIOutput is the response schema for run_cli: the shaped CLI result, never +// raw API JSON beyond the captured stdout the provider emitted. +type RunCLIOutput = CLIResult + +// runCLI validates the argv against the allowlist, deny floor, and scope, then +// execs it through the no-shell core. A rejected argv is a tool error returned +// before any exec; a non-zero CLI exit is a normal result the agent sees. +func (s *Server) runCLI(ctx context.Context, _ *mcp.CallToolRequest, in RunCLIInput) (*mcp.CallToolResult, RunCLIOutput, error) { + res, err := s.run(ctx, in.Argv) + if err != nil { + return errorResult(fmt.Sprintf("run_cli rejected: %v", err)), RunCLIOutput{}, nil + } + return nil, res, nil +} + +// ListAllowedCommandsInput is the input schema for list_allowed_commands. It +// takes no parameters. +type ListAllowedCommandsInput struct{} + +// ListAllowedCommandsOutput is the response schema for list_allowed_commands. +type ListAllowedCommandsOutput struct { + Commands []Command `json:"commands"` +} + +// listAllowedCommands returns the same CommandAllowlist run_cli enforces, so the +// catalog and the gate can never disagree. +func (s *Server) listAllowedCommands(_ context.Context, _ *mcp.CallToolRequest, _ ListAllowedCommandsInput) (*mcp.CallToolResult, ListAllowedCommandsOutput, error) { + return nil, ListAllowedCommandsOutput{Commands: s.allowlist.Commands}, nil +} + +// errorResult builds an MCP error result whose Content carries msg. +func errorResult(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: msg}}, + } +} diff --git a/pkg/mcp/cloud/tools_inventory.go b/pkg/mcp/cloud/tools_inventory.go new file mode 100644 index 00000000..6d2cbc57 --- /dev/null +++ b/pkg/mcp/cloud/tools_inventory.go @@ -0,0 +1,30 @@ +package cloud + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const descListInventory = "List the cloud projects (GCP) or accounts (AWS) the pinned read-only identity can see, so you can orient before drilling in. Each entry carries the deployment's free-form tags (e.g. prod, payments) so you can judge which target an investigation belongs to. Read-only." + +// ListInventoryInput is the input schema for list_inventory. It takes no +// parameters: the accessible scope is fixed by the pinned identity. +type ListInventoryInput struct{} + +// ListInventoryOutput is the response schema for list_inventory: the provider's +// accessible scopes. +type ListInventoryOutput = Inventory + +// listInventory projects the provider's accessible scopes. It execs through the +// validated-but-ungated run core: inventory is how the agent discovers which +// targets it may select, so it must not require an active target first (the same +// path selectableTargets uses). +func (s *Server) listInventory(ctx context.Context, _ *mcp.CallToolRequest, _ ListInventoryInput) (*mcp.CallToolResult, ListInventoryOutput, error) { + inv, err := s.provider.Inventory(ctx, s.runValidated) + if err != nil { + return errorResult(fmt.Sprintf("list inventory: %v", err)), ListInventoryOutput{}, nil + } + return nil, inv, nil +} diff --git a/pkg/mcp/cloud/tools_status.go b/pkg/mcp/cloud/tools_status.go new file mode 100644 index 00000000..c78c22bf --- /dev/null +++ b/pkg/mcp/cloud/tools_status.go @@ -0,0 +1,29 @@ +package cloud + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const descSessionStatus = "Report the pinned read-only cloud identity this session acts as and whether it is currently valid. You cannot choose or change it. Read-only." + +// SessionStatusInput is the input schema for session_status. It takes no +// parameters: the identity is pinned by the deployment. +type SessionStatusInput struct{} + +// SessionStatusOutput is the response schema for session_status. It is the +// shared IdentityStatus the connections panel and preflight gate also render. +type SessionStatusOutput = IdentityStatus + +// sessionStatus runs the shared identity probe and returns its result. It never +// errors on an invalid identity — a stale credential surfaces as Valid:false +// with a Hint, the same visible-degrade contract the launcher renders. +func (s *Server) sessionStatus(ctx context.Context, _ *mcp.CallToolRequest, _ SessionStatusInput) (*mcp.CallToolResult, SessionStatusOutput, error) { + st, err := Probe(ctx, s.provider, s.expectedIdentityForActive(), s.subprocessEnv()) + if err != nil { + return errorResult(err.Error()), SessionStatusOutput{}, nil + } + st.ActiveTarget = s.activeTarget + return nil, st, nil +} diff --git a/pkg/mcp/cloud/tools_target.go b/pkg/mcp/cloud/tools_target.go new file mode 100644 index 00000000..8b86c4d4 --- /dev/null +++ b/pkg/mcp/cloud/tools_target.go @@ -0,0 +1,31 @@ +package cloud + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const descSetActiveTarget = "Choose which project (GCP) or account (AWS) subsequent run_cli commands run against, from the configured set shown by list_inventory. You cannot choose a target outside that set. Read-only." + +// SetActiveTargetInput is the input schema for set_active_target. +type SetActiveTargetInput struct { + Target string `json:"target" jsonschema:"The project id (GCP) or account id (AWS) to activate, from list_inventory."` +} + +// SetActiveTargetOutput is the response schema for set_active_target: the new +// target's session_status, so the agent immediately sees whether it is valid. +type SetActiveTargetOutput = IdentityStatus + +// setActiveTarget pins the active target after validating it against the +// selectable set, then re-probes so the returned status reflects the new +// target. A target outside the set is rejected before anything changes. +func (s *Server) setActiveTarget(ctx context.Context, _ *mcp.CallToolRequest, in SetActiveTargetInput) (*mcp.CallToolResult, SetActiveTargetOutput, error) { + if err := s.setActive(in.Target); err != nil { + return errorResult(fmt.Sprintf("set_active_target rejected: %v", err)), SetActiveTargetOutput{}, nil + } + st, _ := Probe(ctx, s.provider, s.expectedIdentityForActive(), s.subprocessEnv()) + st.ActiveTarget = s.activeTarget + return nil, st, nil +} diff --git a/pkg/mcp/cloud/tools_test.go b/pkg/mcp/cloud/tools_test.go new file mode 100644 index 00000000..9d417219 --- /dev/null +++ b/pkg/mcp/cloud/tools_test.go @@ -0,0 +1,169 @@ +package cloud + +import ( + "context" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +func newTestServer(t *testing.T, p Provider, opts ...func(*Options)) *Server { + t.Helper() + o := Options{Provider: p} + for _, f := range opts { + f(&o) + } + srv, err := New(o) + require.NoError(t, err) + return srv +} + +// errText reads the text content of a tool error result. +func errText(res *mcp.CallToolResult) string { + var b strings.Builder + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + b.WriteString(tc.Text) + } + } + return b.String() +} + +func TestRunCLIRequiresActiveTargetWhenMultiple(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "a"}, {ID: "b"}}, binary: "/bin/echo", + allowlist: &CommandAllowlist{Commands: []Command{{Path: "echo"}}}}) + res, _, _ := s.runCLI(context.Background(), nil, RunCLIInput{Argv: []string{"echo", "x"}}) + require.True(t, res.IsError) + require.Contains(t, errText(res), "set_active_target") + + require.NoError(t, s.setActive("a")) + res2, out2, err2 := s.runCLI(context.Background(), nil, RunCLIInput{Argv: []string{"echo", "x"}}) + require.NoError(t, err2) + require.Nil(t, res2, "with an active target the command runs (no error result)") + require.Contains(t, out2.Stdout, "x") +} + +func TestSessionStatusReportsActiveTarget(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", AssumedIdentity: "ro@acct-1", Valid: true}}) + require.NoError(t, s.setActive("acct-1")) + _, out, _ := s.sessionStatus(context.Background(), nil, SessionStatusInput{}) + require.Equal(t, "acct-1", out.ActiveTarget) +} + +func TestSetActiveTargetTool(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", AssumedIdentity: "ro@acct-1", Valid: true}}) + _, out, err := s.setActiveTarget(context.Background(), nil, SetActiveTargetInput{Target: "acct-1"}) + require.NoError(t, err) + require.True(t, out.Valid) + require.Equal(t, "acct-1", s.activeTarget) + + res, _, _ := s.setActiveTarget(context.Background(), nil, SetActiveTargetInput{Target: "nope"}) + require.True(t, res.IsError) +} + +func TestListInventoryReturnsProviderScopes(t *testing.T) { + t.Parallel() + p := &fakeProvider{inventory: Inventory{Scopes: []Scope{{ID: "prod", Name: "Production"}}}} + srv := newTestServer(t, p) + _, out, err := srv.listInventory(context.Background(), nil, ListInventoryInput{}) + require.NoError(t, err) + require.Len(t, out.Scopes, 1) + require.Equal(t, "prod", out.Scopes[0].ID) +} + +func TestListInventoryDescriptionSurfacesTags(t *testing.T) { + t.Parallel() + // The output schema is not introspected into the tool spec, so the + // description is the agent's only prompt-time signal that each entry + // carries deployment tags. Guard that it says so. + require.Contains(t, descListInventory, "tags") +} + +func TestSessionStatusReturnsProbeIdentity(t *testing.T) { + t.Parallel() + p := &fakeProvider{ + name: "gcp", + identity: IdentityStatus{Provider: "gcp", AssumedIdentity: "ro-sa@proj", Valid: true}, + } + srv := newTestServer(t, p) + _, out, err := srv.sessionStatus(context.Background(), nil, SessionStatusInput{}) + require.NoError(t, err) + require.True(t, out.Valid) + require.Equal(t, "ro-sa@proj", out.AssumedIdentity) +} + +func TestListAllowedCommandsReturnsLoadedAllowlist(t *testing.T) { + t.Parallel() + p := &fakeProvider{allowlist: &CommandAllowlist{Commands: []Command{ + {Path: "projects list", Description: "orient: list projects"}, + }}} + srv := newTestServer(t, p) + _, out, err := srv.listAllowedCommands(context.Background(), nil, ListAllowedCommandsInput{}) + require.NoError(t, err) + require.Len(t, out.Commands, 1) + require.Equal(t, "projects list", out.Commands[0].Path) +} + +func TestListAllowedCommandsDropsDenyFlooredEntries(t *testing.T) { + t.Parallel() + // Even if a provider default lists a floored command, the catalog the agent + // sees is exactly what run_cli enforces — the floored entry is absent. + p := &fakeProvider{allowlist: &CommandAllowlist{Commands: []Command{ + {Path: "projects list"}, + {Path: "secrets versions access"}, + }}} + srv := newTestServer(t, p) + _, out, err := srv.listAllowedCommands(context.Background(), nil, ListAllowedCommandsInput{}) + require.NoError(t, err) + for _, c := range out.Commands { + require.NotEqual(t, "secrets versions access", c.Path, "deny-floored command must not be advertised") + } +} + +func TestRunCLIRejectsDenyFlooredArgvBeforeExec(t *testing.T) { + t.Parallel() + p := &fakeProvider{ + binary: "/bin/echo", + allowlist: &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}}, + } + srv := newTestServer(t, p) + res, _, err := srv.runCLI(context.Background(), nil, RunCLIInput{ + Argv: []string{"compute", "instances", "list", "--impersonate-service-account", "evil"}, + }) + require.NoError(t, err) + require.NotNil(t, res) + require.True(t, res.IsError, "deny-floored argv must be rejected as a tool error before exec") +} + +func TestRunCLIShapesResultOnSuccess(t *testing.T) { + t.Parallel() + p := &fakeProvider{ + binary: "/bin/echo", + allowlist: &CommandAllowlist{Commands: []Command{{Path: "projects list"}}}, + } + srv := newTestServer(t, p) + _, out, err := srv.runCLI(context.Background(), nil, RunCLIInput{Argv: []string{"projects", "list"}}) + require.NoError(t, err) + require.Contains(t, out.Stdout, "projects list") +} + +func TestRunCLIRejectsOutOfScopeTarget(t *testing.T) { + t.Parallel() + p := &fakeProvider{ + binary: "/bin/echo", + allowlist: &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}}, + } + srv := newTestServer(t, p, func(o *Options) { + o.Scope = ScopeAllowlist{Regions: []string{"us-central1"}} + }) + res, _, err := srv.runCLI(context.Background(), nil, RunCLIInput{ + Argv: []string{"compute", "instances", "list", "--region", "eu-west1"}, + }) + require.NoError(t, err) + require.True(t, res.IsError, "out-of-scope target must be rejected") +} diff --git a/pkg/mcp/cloud/tools_wire_test.go b/pkg/mcp/cloud/tools_wire_test.go new file mode 100644 index 00000000..4042c67a --- /dev/null +++ b/pkg/mcp/cloud/tools_wire_test.go @@ -0,0 +1,51 @@ +package cloud + +import ( + "context" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTools_Registered confirms the cloud tools are exposed and that the +// set registered on the server matches the ToolSpecs() catalog exactly — the +// wire test fails if registration drifts from the catalog. +func TestTools_Registered(t *testing.T) { + t.Parallel() + + srv, err := New(Options{Provider: &fakeProvider{}}) + require.NoError(t, err) + + serverT, clientT := sdkmcp.NewInMemoryTransports() + serverSession, err := srv.impl.Connect(context.Background(), serverT, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "test-client", Version: "v0"}, nil) + clientSession, err := client.Connect(context.Background(), clientT, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + list, err := clientSession.ListTools(context.Background(), &sdkmcp.ListToolsParams{}) + require.NoError(t, err) + + registered := map[string]bool{} + for _, tool := range list.Tools { + registered[tool.Name] = true + } + + cataloged := map[string]bool{} + for _, spec := range ToolSpecs() { + cataloged[spec.Name] = true + assert.True(t, registered[spec.Name], "tool %q in ToolSpecs() but not registered", spec.Name) + } + for name := range registered { + assert.True(t, cataloged[name], "tool %q registered but absent from ToolSpecs()", name) + } + + for _, want := range []string{"list_inventory", "session_status", "set_active_target", "run_cli", "list_allowed_commands"} { + assert.True(t, registered[want], "%s not registered", want) + } +} diff --git a/pkg/mcp/cloud/validate.go b/pkg/mcp/cloud/validate.go new file mode 100644 index 00000000..2e2f8391 --- /dev/null +++ b/pkg/mcp/cloud/validate.go @@ -0,0 +1,147 @@ +package cloud + +import ( + "fmt" + "strings" +) + +// ScopeAllowlist constrains which cloud targets a run_cli argv may reference. An +// empty field means that axis is unconstrained. Only region/zone is enforced here +// against argv (allowedFor maps --region/--zone). The selectable project (gcp) / +// account (aws) set is not an argv axis: it is the deployment's configured target +// list (the provider's projects/accounts), chosen via set_active_target, and the +// target-selecting flags (--project, --account, --profile) sit on the deny floor. +// Accounts here is an informational note (the AWS accounts a source documents); +// account reach is bounded by the pinned roles, not by argv. +type ScopeAllowlist struct { + Accounts []string `json:"accounts,omitempty"` + Regions []string `json:"regions,omitempty"` +} + +// allowedFor maps a target-selecting flag to the ScopeAllowlist field whose +// membership a value of that flag must satisfy. Only the region/zone axis is +// scoped at the argv layer; the identity flags (--account, --profile) and the +// project override (--project) sit on the deny floor, rejected before scope ever +// sees them, so scope governs only the location axes the agent may choose among. +func (s ScopeAllowlist) allowedFor(flag string) ([]string, bool) { + switch flag { + case "--region", "--zone": + return s.Regions, true + default: + return nil, false + } +} + +// validateArgv enforces the no-bypass contract on one argv before exec: no +// token carries a shell-control sequence, the positional subcommand path is on +// the allowlist, no token is a deny-floored flag or arg-prefix, and every +// target-selecting flag value is within scope. It runs entirely on argv tokens — +// there is no shell, so a shell-control token would be an inert positional +// anyway; the metachar check rejects it outright as defense in depth. +func validateArgv(argv []string, allow *CommandAllowlist, scope ScopeAllowlist) error { + if len(argv) == 0 { + return fmt.Errorf("empty command") + } + for _, tok := range argv { + if isShellControlToken(tok) { + return fmt.Errorf("argv token contains a shell-control character: %q", tok) + } + } + if !allow.Allows(argv) { + return fmt.Errorf("subcommand not on the allowlist: %q", strings.Join(subcommandPath(argv), " ")) + } + + floor := denyFloor // base floor; provider additions are filtered at load time. + for i := 0; i < len(argv); i++ { + tok := argv[i] + flag, value, hasInlineValue := splitFlag(tok) + + if strings.HasPrefix(flag, "-") { + if floorDeniesFlag(floor, flag) { + return fmt.Errorf("flag is on the deny floor: %s", flag) + } + // Resolve the flag's value: inline (--flag=value) or the next token. + val := value + if !hasInlineValue && i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "-") { + val = argv[i+1] + } + if val != "" { + if err := checkArgPrefix(floor, val); err != nil { + return err + } + if allowed, scoped := scope.allowedFor(flag); scoped { + if err := checkScope(flag, val, allowed); err != nil { + return err + } + } + } + continue + } + // Positional token: still subject to the arg-prefix floor. + if err := checkArgPrefix(floor, tok); err != nil { + return err + } + } + return nil +} + +// isShellControlToken reports whether tok is or contains a shell-control +// sequence. The harness never invokes a shell, so these are already inert; this +// is defense in depth, rejecting `;`, `|`, `&`, backtick, `$(`, `>`, `<`, and +// embedded newlines so an argv like ["...", "describe", ";", "rm"] is refused +// outright. A literal resource name ("my-vm") or a key=value flag +// ("--filter=name=foo") contains none of these and passes. +func isShellControlToken(tok string) bool { + if strings.ContainsAny(tok, ";|&`<>\n") { + return true + } + return strings.Contains(tok, "$(") +} + +// splitFlag separates a "--flag=value" token into its flag and value. For a +// bare "--flag" or a non-flag token it returns the token unchanged with no +// inline value. +func splitFlag(tok string) (flag, value string, hasInlineValue bool) { + if !strings.HasPrefix(tok, "-") { + return tok, "", false + } + if eq := strings.IndexByte(tok, '='); eq >= 0 { + return tok[:eq], tok[eq+1:], true + } + return tok, "", false +} + +// floorDeniesFlag reports whether flag matches a deny-floored flag name. +func floorDeniesFlag(floor DenyFloor, flag string) bool { + for _, f := range floor.Flags { + if flag == f { + return true + } + } + return false +} + +// checkArgPrefix rejects an argument value beginning with a deny-floored prefix +// (local-file read and SSRF vectors). +func checkArgPrefix(floor DenyFloor, val string) error { + for _, p := range floor.ArgPrefixes { + if strings.HasPrefix(val, p) { + return fmt.Errorf("argument value has a denied prefix %q: %s", p, val) + } + } + return nil +} + +// checkScope rejects a target-selecting flag value outside the allowlist. An +// empty allowlist means the axis is unconstrained. +func checkScope(flag, val string, allowed []string) error { + if len(allowed) == 0 { + return nil + } + for _, a := range allowed { + if val == a { + return nil + } + } + return fmt.Errorf("%s %q is outside the allowed scope", flag, val) +} diff --git a/pkg/mcp/cloud/validate_test.go b/pkg/mcp/cloud/validate_test.go new file mode 100644 index 00000000..e00e6289 --- /dev/null +++ b/pkg/mcp/cloud/validate_test.go @@ -0,0 +1,109 @@ +package cloud + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}} + scope := ScopeAllowlist{Regions: []string{"us-central1"}} + cases := []struct { + name string + argv []string + ok bool + }{ + {"allowed", []string{"compute", "instances", "list"}, true}, + {"allowed-region", []string{"compute", "instances", "list", "--region", "us-central1"}, true}, + {"project-flag-floored", []string{"compute", "instances", "list", "--project", "prod"}, false}, + {"bad-region", []string{"compute", "instances", "list", "--region", "eu-west1"}, false}, + {"impersonate", []string{"compute", "instances", "list", "--impersonate-service-account", "x"}, false}, + {"account-flag", []string{"compute", "instances", "list", "--account", "evil"}, false}, + {"profile-flag", []string{"compute", "instances", "list", "--profile", "evil"}, false}, + {"endpoint-flag", []string{"compute", "instances", "list", "--endpoint-url", "http://evil"}, false}, + {"flags-file", []string{"compute", "instances", "list", "--flags-file", "/tmp/evil.yaml"}, false}, + {"access-token-file", []string{"compute", "instances", "list", "--access-token-file", "/tmp/tok"}, false}, + {"log-http", []string{"compute", "instances", "list", "--log-http"}, false}, + {"debug", []string{"compute", "instances", "list", "--debug"}, false}, + {"file-prefix", []string{"compute", "instances", "list", "--filter", "@/etc/passwd"}, false}, + {"fileurl-prefix", []string{"compute", "instances", "list", "--filter", "file:///etc/passwd"}, false}, + {"httpurl-prefix", []string{"compute", "instances", "list", "--filter", "https://evil"}, false}, + {"metachar-semicolon", []string{"compute", "instances", "list", ";", "rm", "-rf", "/"}, false}, + {"metachar-pipe", []string{"compute", "instances", "list", "|", "cat"}, false}, + {"metachar-subshell", []string{"compute", "instances", "list", "$(whoami)"}, false}, + {"metachar-backtick", []string{"compute", "instances", "list", "`id`"}, false}, + {"metachar-redirect", []string{"compute", "instances", "list", ">", "/tmp/x"}, false}, + {"metachar-and", []string{"compute", "instances", "list", "&&", "rm"}, false}, + {"metachar-embedded", []string{"compute", "instances", "list", "--filter=a$(id)"}, false}, + {"not-allowed", []string{"iam", "service-accounts", "create"}, false}, + {"empty", []string{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateArgv(tc.argv, al, scope) + if tc.ok { + assert.NoError(t, err, "expected argv to validate") + } else { + assert.Error(t, err, "expected validation error") + } + }) + } +} + +func TestValidateArgvEqualsFormFlag(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}} + scope := ScopeAllowlist{Regions: []string{"us-central1"}} + // An out-of-scope value in equals form must be caught by the scope check, and + // a deny-floored flag in equals form must be caught by the floor. + assert.Error(t, validateArgv([]string{"compute", "instances", "list", "--region=eu-west1"}, al, scope), + "expected --region=eu-west1 (equals form) to fail the scope check") + assert.Error(t, validateArgv([]string{"compute", "instances", "list", "--impersonate-service-account=x"}, al, scope), + "expected --impersonate-service-account=x (equals form) to be denied") + assert.Error(t, validateArgv([]string{"compute", "instances", "list", "--project=prod"}, al, scope), + "expected --project=prod (equals form) to be denied by the floor") + assert.NoError(t, validateArgv([]string{"compute", "instances", "list", "--region=us-central1"}, al, scope), + "expected --region=us-central1 (equals form, in scope) to validate") +} + +func TestValidateArgvAllowsResourceOperand(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances describe"}}} + scope := ScopeAllowlist{} + // describe/get verbs take a resource operand; the allowlisted verb chain + // matches as a prefix, and the operand is an inert positional argument. + assert.NoError(t, validateArgv( + []string{"compute", "instances", "describe", "my-vm"}, al, scope), + "an allowlisted verb chain plus a resource operand must validate") +} + +func TestValidateArgvRejectsMetacharInAnyPosition(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances describe"}}} + scope := ScopeAllowlist{} + for _, argv := range [][]string{ + {"compute", "instances", "describe", "my-vm", ";", "rm"}, + {"compute", "instances", "describe", ";", "my-vm"}, + {"compute", "instances", "describe", "my-vm|cat"}, + {"compute", "instances", "describe", "my-vm", "&&", "id"}, + } { + assert.Errorf(t, validateArgv(argv, al, scope), + "a metacharacter token in %v must be rejected", argv) + } + // A literal resource name and a key=value filter contain no shell-control + // characters and must pass. + assert.NoError(t, validateArgv( + []string{"compute", "instances", "describe", "my-vm", "--filter=name=foo"}, al, scope), + "a plain resource name and a key=value filter must pass") +} + +func TestValidateArgvEmptyScopeAllowsAnyTarget(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}} + // An empty scope means the deployment did not constrain the region axis; the + // scope check must not reject a --region then. + assert.NoError(t, validateArgv([]string{"compute", "instances", "list", "--region", "anything"}, al, ScopeAllowlist{}), + "empty scope should not reject a target") +} diff --git a/system/cloud_triage.yaml b/system/cloud_triage.yaml new file mode 100644 index 00000000..1a898690 --- /dev/null +++ b/system/cloud_triage.yaml @@ -0,0 +1,188 @@ +id: cloud_triage +schema_version: 1 +type: general +symptom: "An investigation has surfaced a cloud-shaped signal — an account id, role or service-account ARN, a cloud resource name, or a cloud permission / quota / throttle error — that looks suspicious or causal, or the user has asked you to check the cloud" +description: | + Sub-flow the agent walks when a Kubernetes investigation needs to + follow a thread down into the cloud layer (GCP or AWS) through the + read-only triagent-cloud- MCP. It is provider-agnostic: the + cloud tools (list_inventory, set_active_target, session_status, + run_cli, list_allowed_commands) are neutral across GCP and AWS, and + every read is bounded by the MCP's command allowlist, deny floor, and + read-only pinned identity. + + This playbook exists to impose discipline the raw capability lacks: + + 1. **Do not enter the cloud without a cloud-shaped signal.** Having + cloud tools is not a reason to use them. The gate turns you back + when the symptom is cluster-internal. + 2. **Pin the target before you read.** Establish which project / + account and region you are looking at — preferably from the + cluster itself — and make it active, so reads land in the right + scope instead of an ambient default. + 3. **Run the cheapest read that tests your hypothesis,** along the + axis the signal points to, then correlate the finding back to + the cluster symptom and timeline. + + Use this as a sub-flow: walk it, get a citable answer (or a clean + "cloud ruled out"), then return to the parent investigation. + +# Entity tags so playbook_correlate ranks this for cloud-shaped queries. +errors: + - permission-denied + - forbidden + - access-denied + - quota-exceeded + - throttled +symptoms: + - dependency-unreachable + - connection-timeout + - iam-denied + +entrypoint: gate + +nodes: + gate: + description: | + Decide whether the cloud is even in scope before touching it. + + A cloud-shaped signal is one of: + - an account id, project id, role ARN, or service-account + identity that surfaced in the evidence and looks involved; + - a cloud resource name (a load balancer, bucket, managed + database, VPC/subnet/security-group, NAT/gateway) named in an + error, event, or config; + - a cloud permission, quota, or throttling error + (PERMISSION_DENIED, AccessDenied, Forbidden, quota exceeded, + rate/throttle); + - the operator explicitly asked you to check the cloud. + + If none of these is present, the symptom is cluster-internal — an + application bug, a Kubernetes misconfiguration, a bad image, an + OOM, a failing readiness probe — and you should NOT go looking in + the cloud. Having read-only cloud tools is not a reason to use + them. Name the signal you are acting on before proceeding. + + Call step_complete with findings=[{key: "cloud_signal", + value: ""}]. + expected_findings: + - cloud_signal + next: + - condition: "a concrete cloud-shaped signal is present, or the operator asked you to check the cloud — orient before reading" + goto: orient + - condition: "no cloud-shaped signal — the symptom is cluster-internal; do not search the cloud" + goto: terminal_no_signal + + orient: + description: | + Pin the project/account and region BEFORE any cloud read, so the + read lands in the right scope rather than an ambient default. + + Derive the coordinates from the cluster first — it usually knows + where it runs: + - The node the workload sits on carries the cloud account/project + and region: read the Node with triagent-k8s (get_resource) and + look at `spec.providerID` (encodes the cloud + account/project + + instance) and the `topology.kubernetes.io/region` / + `topology.kubernetes.io/zone` labels. + - The workload's ServiceAccount often carries a workload-identity + annotation naming the cloud identity + (`iam.gke.io/gcp-service-account` on GCP, + `eks.amazonaws.com/role-arn` on AWS). + + Then reconcile against the configured cloud source: + - `session_status` reports the identity and the target currently + pinned. + - `list_inventory` lists the projects/accounts the source is + configured to reach, each with deployment tags. + - If the target matching your signal's account/project is not + already active, `set_active_target` to it (match by id, use the + tags to disambiguate). + + Call step_complete with findings=[{key: "active_target", + value: ""}]. + suggested_calls: + - tool: triagent-k8s/get_resource + expected_findings: + - active_target + next: + - condition: "the target matching the signal is active and you know the region — investigate" + goto: investigate + - condition: "no configured cloud target matches the signal's account/project (or no cloud source is wired) — cannot proceed" + goto: terminal_blocked + + investigate: + description: | + Run the cheapest read that tests your hypothesis, along the axis + the signal points to. If unsure what is permitted, call + `list_allowed_commands` first; it returns exactly what `run_cli` + will accept. + + - **Reachability** (a Pod cannot reach a dependency, timeouts): + inspect security groups / firewall rules, subnets, routes, + NAT/gateways, network ACLs. + - **Permissions** (denied / forbidden): read the IAM policy, + role, or binding for the identity involved; simulate the + action where the provider supports it. + - **Cluster config** (the managed cluster behaves oddly): read + the GKE/EKS cluster networking and node configuration. + - **Logs / audit** ("what changed right before this broke?"): + read cloud logs, and the change-audit trail (CloudTrail on + AWS, admin-activity logs on GCP) around the incident window. + + Every call goes through `run_cli` and is read-only by + construction — do not attempt writes or secret reads; they are + denied at the harness and at the identity. Correlate whatever you + find back to the cluster symptom and the incident timeline: a + finding only matters if it explains or rules out the failure. + + Call step_complete with findings=[{key: "cloud_finding", + value: ""}]. + expected_findings: + - cloud_finding + next: + - condition: "a cloud finding explains or cleanly rules out the cluster symptom — done" + goto: terminal_done + - condition: "the read you need is outside the allowlist or the pinned identity cannot see it — cannot proceed" + goto: terminal_blocked + + terminal_done: + description: "Cloud check complete. Surface the result to the parent investigation." + terminal_advice: | + Hand the result back to the parent investigation as a short, + citable bullet — the resource, the finding, and the time: + + - `sg-0abc123` denies tcp/5432 from the node subnet — explains + the payment-api → RDS connection timeouts since 14:05Z. + - IAM: the workload's role lost `secretsmanager:GetSecretValue` + (CloudTrail `PutRolePolicy` at 13:58Z) — explains the + AccessDenied at startup. + - or: cloud ruled out — reachability, IAM, and the audit trail + for the window are clean; the cause is in the cluster. + + This playbook does NOT call summarize — it is a sub-flow. The + parent investigation reaches summarize on its own. + + terminal_no_signal: + description: "No cloud-shaped signal — stay in the cluster." + terminal_advice: | + Tell the operator explicitly: "No cloud signal surfaced (no + account/identity, cloud resource, or cloud permission error), so + I'm not searching the cloud — this looks cluster-internal." + Return to the parent investigation and continue there. Do not + re-enter this playbook until a concrete cloud signal appears. + + terminal_blocked: + description: "Cannot complete the cloud check — return to parent with what you have." + terminal_advice: | + Name precisely why the check could not complete, then continue the + investigation in-cluster: + + - **No configured target.** The signal points at an + account/project the deployment did not wire into its `cloud:` + sources (or no cloud source is configured at all). The + operator would need to add it; you cannot reach it. + - **Read not permitted.** The read you need is outside the + command allowlist, or the pinned read-only identity lacks the + permission. Do not try to widen either — say which command or + permission would be required and move on. diff --git a/system/embed_test.go b/system/embed_test.go index 5bc755f3..f6b843a7 100644 --- a/system/embed_test.go +++ b/system/embed_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/sourcehawk/triagent/pkg/mcp/strategies" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" @@ -45,6 +46,49 @@ func TestExtract_IncludesPRProposal(t *testing.T) { assert.Contains(t, s, "terminal_awaiting_review") } +// TestExtract_IncludesCloudTriage confirms the cloud-triage sub-flow ships +// with the embedded set, parses, and validates structurally (entrypoint and +// every goto resolve to its own nodes, no empty descriptions) the same way +// the strategies MCP checks a playbook at load time. +func TestExtract_IncludesCloudTriage(t *testing.T) { + t.Parallel() + root := t.TempDir() + typeDir, err := Extract(root) + require.NoError(t, err) + + body, err := os.ReadFile(filepath.Join(typeDir, "cloud_triage.yaml")) + require.NoError(t, err, "cloud_triage.yaml not extracted") + + var head struct { + ID string `yaml:"id"` + SchemaVersion int `yaml:"schema_version"` + Type string `yaml:"type"` + Entrypoint string `yaml:"entrypoint"` + } + require.NoError(t, yaml.Unmarshal(body, &head)) + assert.Equal(t, "cloud_triage", head.ID) + assert.Equal(t, 1, head.SchemaVersion) + assert.Equal(t, "general", head.Type) + assert.Equal(t, "gate", head.Entrypoint) + + // Real structural validation: entrypoint resolves, every goto resolves, + // no empty node descriptions. + _, errs := strategies.ParseAndValidatePlaybookYAML(body) + assert.Empty(t, errs, "cloud_triage.yaml failed structural validation: %v", errs) + + s := string(body) + for _, node := range []string{ + "gate", + "orient", + "investigate", + "terminal_done", + "terminal_no_signal", + "terminal_blocked", + } { + assert.Contains(t, s, node+":", "cloud_triage must define node %q", node) + } +} + // TestExtract_CaptureOfferV3HasBugReportRoute confirms that capture_offer was // bumped to v3 and that the bug-report terminal is wired alongside the // existing codefix / all / both terminals (which must remain for backward