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)