Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions docs/content/cloud-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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:
Expand All @@ -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

Expand Down
10 changes: 8 additions & 2 deletions frontend/components/ConnectionsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Expand All @@ -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,
Expand All @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions frontend/components/ConnectionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ function CloudConnectionsSection({ cloud }: { cloud: CloudConnection[] }) {
starting a session.
</p>
<div className="space-y-2">
{cloud.map((c, i) => (
<CloudPill key={`${c.provider}-${c.assumed_identity}-${i}`} conn={c} />
{cloud.map((c) => (
<CloudPill key={c.alias} conn={c} />
))}
</div>
</div>
Expand All @@ -241,12 +241,12 @@ function CloudPill({ conn }: { conn: CloudConnection }) {
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-zinc-200">
{conn.alias}
</span>
<span className="text-[10px] uppercase tracking-wide text-zinc-500">
{conn.provider}
</span>
<span className="truncate font-mono text-xs text-zinc-200">
{conn.assumed_identity}
</span>
</div>
{conn.valid ? (
<span
Expand All @@ -262,6 +262,9 @@ function CloudPill({ conn }: { conn: CloudConnection }) {
</span>
)}
</div>
<div className="mt-1 truncate font-mono text-xs text-zinc-400">
{conn.assumed_identity}
</div>
{!conn.valid && conn.hint && (
<div className="mt-1 text-xs text-amber-200/70">{conn.hint}</div>
)}
Expand Down
8 changes: 5 additions & 3 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<alias> 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;
Expand Down
28 changes: 26 additions & 2 deletions internal/preflight/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,21 @@ 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,
Namespace: opts.Namespace,
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,
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions internal/preflight/preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package preflight

import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
Expand Down Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions internal/server/handlers_connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<alias> 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"`
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions internal/server/handlers_connections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)
Expand Down
Loading