diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 8d023bd..4bc6dbf 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -66,8 +66,8 @@ cloud: # 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 - # Targets any run_cli argv may reference. An empty axis is unconstrained; - # a non-empty axis means the agent cannot pivot outside it. + # Project and region/zone targets enforced on run_cli argv. An empty axis + # is unconstrained; a non-empty axis means the agent cannot pivot outside it. scope: projects: [prod-platform, prod-data] regions: [us-central1, us-east1] @@ -85,8 +85,8 @@ cloud: # source_profile. gcp ignores this field. profile: triage-readonly scope: - accounts: ["123456789012"] - regions: [eu-west-1] + regions: [eu-west-1] # enforced on run_cli argv. + accounts: ["123456789012"] # informational; account reach is bounded by the pinned role. ``` The fields: @@ -100,16 +100,20 @@ The fields: ## Scope allowlist -`scope` constrains which cloud targets any `run_cli` argument may reference, so the agent cannot pivot to an un-allowlisted project, account, or region. It has three axes: +`scope` constrains which cloud targets a `run_cli` argument may reference, so the agent cannot pivot to an un-allowlisted project or region. The argv-enforced axes are project and region/zone; account reach is governed by the pinned role or profile, not by argv. ```yaml scope: - projects: [prod-platform] # gcp --project values the agent may use - accounts: ["123456789012"] # aws account ids the agent may use - regions: [us-central1] # --region / --zone values the agent may use + projects: [prod-platform] # gcp --project values the agent may use (argv-enforced) + 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) axis is unconstrained on that axis. A non-empty axis is a closed set: a `--project`, `--region`, or `--zone` value outside it fails validation before the command runs. Identity-selecting flags (`--account`, `--profile`) never reach scope validation at all, because the deny floor rejects them first. +An empty (or omitted) `projects` or `regions` axis is unconstrained on that axis. A non-empty one is a closed set: a `--project`, `--region`, or `--zone` value outside it fails validation before the command runs. + +`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-selecting flags (`--account`, `--profile`) never reach scope validation at all, because the deny floor rejects them first. ## Command allowlist diff --git a/frontend/components/ConnectionsPanel.test.tsx b/frontend/components/ConnectionsPanel.test.tsx index 66e0c11..1789ee0 100644 --- a/frontend/components/ConnectionsPanel.test.tsx +++ b/frontend/components/ConnectionsPanel.test.tsx @@ -30,16 +30,18 @@ describe("ConnectionsPanel cloud pills", () => { vi.restoreAllMocks(); }); - it("renders a read-only cloud pill per entry with the assumed identity", async () => { + it("renders a read-only cloud pill per entry with the alias and assumed identity", async () => { vi.spyOn(api, "getConnections").mockResolvedValue({ ...baseStatus, cloud: [ { + alias: "prod-gcp", provider: "gcp", assumed_identity: "triage-ro@prod.iam.gserviceaccount.com", valid: true, }, { + alias: "prod-aws", provider: "aws", assumed_identity: "arn:aws:iam::1:role/triage-ro", valid: false, @@ -50,8 +52,10 @@ describe("ConnectionsPanel cloud pills", () => { await renderPanelAndOpenModal(); + expect(await screen.findByText("prod-gcp")).toBeInTheDocument(); + expect(screen.getByText("prod-aws")).toBeInTheDocument(); expect( - await screen.findByText("triage-ro@prod.iam.gserviceaccount.com"), + screen.getByText("triage-ro@prod.iam.gserviceaccount.com"), ).toBeInTheDocument(); expect( screen.getByText("arn:aws:iam::1:role/triage-ro"), @@ -63,6 +67,7 @@ describe("ConnectionsPanel cloud pills", () => { ...baseStatus, cloud: [ { + alias: "prod-aws", provider: "aws", assumed_identity: "arn:aws:iam::1:role/triage-ro", valid: false, @@ -81,6 +86,7 @@ describe("ConnectionsPanel cloud pills", () => { ...baseStatus, cloud: [ { + alias: "prod-gcp", provider: "gcp", assumed_identity: "triage-ro@prod.iam.gserviceaccount.com", valid: true, diff --git a/frontend/components/ConnectionsPanel.tsx b/frontend/components/ConnectionsPanel.tsx index 29d15fd..a2d40b0 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/ConnectionsPanel.tsx @@ -225,8 +225,8 @@ function CloudConnectionsSection({ cloud }: { cloud: CloudConnection[] }) { starting a session.

- {cloud.map((c, i) => ( - + {cloud.map((c) => ( + ))}
@@ -241,12 +241,12 @@ function CloudPill({ conn }: { conn: CloudConnection }) { >
+ + {conn.alias} + {conn.provider} - - {conn.assumed_identity} -
{conn.valid ? ( )}
+
+ {conn.assumed_identity} +
{!conn.valid && conn.hint && (
{conn.hint}
)} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 56e12fb..092a715 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -95,10 +95,12 @@ export type ConnectionStatus = { cloud?: CloudConnection[]; }; -// CloudConnection is one read-only cloud source: the pinned identity and the -// request-time identity-probe result. valid drives the checkmark; hint is the -// reauth advice shown when the probe failed. +// CloudConnection is one read-only cloud source: the alias keying its +// triagent-cloud- MCP, the pinned identity, and the request-time +// identity-probe result. valid drives the checkmark; hint is the reauth advice +// shown when the probe failed. export type CloudConnection = { + alias: string; provider: string; assumed_identity: string; valid: boolean; diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 9610cf7..b984783 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -171,6 +171,13 @@ 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. + cloudStatuses := probeCloudSources(opts.Ctx, cloudSources(opts.Profile), opts.CloudProbe) + mcpPath, err := writeMCPConfig(mcpConfigInputs{ Dir: opts.SessionDir, MCPBin: opts.MCPBinaryPath, @@ -178,7 +185,7 @@ func Run(opts Options) (*Result, error) { KubeconfigPath: kubeconfigPath, Profile: opts.Profile, LinkedRepos: opts.LinkedRepos, - CloudSources: cloudSources(opts.Profile), + CloudSources: validCloudSources(cloudSources(opts.Profile), cloudStatuses), GitCacheDir: opts.GitCacheDir, UserPlaybooksDir: opts.UserPlaybooksDir, PluginPlaybooksDir: opts.PluginPlaybooksDir, @@ -206,10 +213,27 @@ func Run(opts Options) (*Result, error) { MCPConfigPath: mcpPath, DocsPrefix: docsPrefix, KubeconfigPath: kubeconfigPath, - CloudSources: probeCloudSources(opts.Ctx, cloudSources(opts.Profile), opts.CloudProbe), + 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 diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go index 17eb816..b088ba1 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" @@ -261,6 +262,26 @@ func TestRun_CloudProbeFailureDegradesNotBlocks(t *testing.T) { 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 diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index 61300c8..41eb43f 100644 --- a/internal/server/handlers_connections.go +++ b/internal/server/handlers_connections.go @@ -33,11 +33,13 @@ type connectionsResponse struct { Cloud []cloudConnection `json:"cloud"` } -// cloudConnection is the read-only view of one profile cloud source: the pinned -// identity and the request-time probe result. It carries no edit affordance — -// cloud is configured in the profile, never entered in the panel. The fields -// mirror cloud.IdentityStatus so the panel renders directly from the probe. +// 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"` AssumedIdentity string `json:"assumed_identity"` Valid bool `json:"valid"` @@ -76,6 +78,7 @@ func (a *apiHandlers) cloudConnections(ctx context.Context) []cloudConnection { for _, src := range a.prof.Cloud { st := probe(ctx, src) out = append(out, cloudConnection{ + Alias: src.Alias, Provider: st.Provider, AssumedIdentity: st.AssumedIdentity, Valid: st.Valid, diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go index 23f41d1..e6243f5 100644 --- a/internal/server/handlers_connections_test.go +++ b/internal/server/handlers_connections_test.go @@ -314,6 +314,7 @@ func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { var resp struct { Cloud []struct { + Alias string `json:"alias"` Provider string `json:"provider"` AssumedIdentity string `json:"assumed_identity"` Valid bool `json:"valid"` @@ -323,10 +324,12 @@ func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) require.Len(t, resp.Cloud, 2) + 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.True(t, resp.Cloud[0].Valid) + assert.Equal(t, "prod-aws", resp.Cloud[1].Alias) assert.Equal(t, "aws", resp.Cloud[1].Provider) assert.False(t, resp.Cloud[1].Valid) assert.Equal(t, "run: aws sso login", resp.Cloud[1].Hint)