From 4fc273b6d298b0d86a48af13e0d3ef81445f113d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 18:13:44 +0200 Subject: [PATCH 1/3] fix(preflight): omit degraded cloud sources from the session MCP config Probe cloud sources before writing the MCP config and wire only the sources whose probe is Valid. A failed probe now disables the source (absent from mcp.json) instead of merely reporting it, honoring the visible-degrade contract. All sources, valid and degraded, remain in Result.CloudSources so the status surface still shows the degraded ones with their hint. The probe still degrades, never blocks the session. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/preflight/preflight.go | 28 ++++++++++++++++++++++++++-- internal/preflight/preflight_test.go | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) 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 From 424a2d5aee907e1c51c6e37b6501d8d8c165ab2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 18:16:49 +0200 Subject: [PATCH 2/3] fix(connections): carry the cloud source alias through /api/connections The cloud DTO exposed provider, identity, valid, and hint but not the alias, so two sources sharing a provider and identity but differing in scope were indistinguishable even though the MCP is keyed triagent-cloud-. Add alias to the DTO and the frontend CloudConnection type, and surface it as the pill heading so each source is identifiable. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/ConnectionsPanel.test.tsx | 10 ++++++++-- frontend/components/ConnectionsPanel.tsx | 13 ++++++++----- frontend/lib/api.ts | 8 +++++--- internal/server/handlers_connections.go | 11 +++++++---- internal/server/handlers_connections_test.go | 3 +++ 5 files changed, 31 insertions(+), 14 deletions(-) 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/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) From fac4f3982b1bb2a34f4bfc65b5c6e0b56e1132ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 18:17:49 +0200 Subject: [PATCH 3/3] docs(cloud): correct the account-scope enforcement claim The Scope allowlist section implied run_cli enforces scope.accounts as an account allowlist. It does not: only --project and --region/--zone are argv-validated. AWS account reach is bounded by the pinned assume-role profile, not by scope.accounts. State that project and region/zone are enforced on argv, while account reach is governed by the pinned role, and mark scope.accounts as informational and reserved so operators do not rely on an allowlist the harness does not enforce. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) 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