From 13a1243bf9f76ae7124d19241869bdd7dc41d8c9 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 04:07:08 +0200 Subject: [PATCH 01/76] docs(spec): read-only cloud-context MCP (GCP and AWS) design One pkg/mcp/cloud/ package bound by --provider, thin typed tools (list_inventory, session_status) plus a gated read-only run_cli escape hatch over a profile-overridable command allowlist with a hardcoded deny floor. Pinned read-only identity via operator-ambient impersonation (env-injected), Workload Identity, or a deferred static-key connection; visible degrade and a shared whoami probe across the connections panel and preflight. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-cloud-context-mcp-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md diff --git a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md new file mode 100644 index 00000000..9ff834de --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md @@ -0,0 +1,117 @@ +# Read-only cloud-context MCP (GCP and AWS) + +## Problem + +When triaging a Kubernetes incident, the operator agent can inspect the cluster (`triagent-k8s`) and reach it through Teleport (`triagent-teleport`), but it is blind to the cloud layer the cluster sits on. A large class of incidents is only explicable from cloud context: a Pod cannot reach a dependency because of a firewall rule or security group or a missing route; a workload is denied because an identity lost a binding or a bucket policy changed; the managed cluster (GKE / EKS) 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. + +Today answering any of these means a human drops into a cloud console, breaking the investigation loop. The two clouds the platform runs on, GCP and AWS, ask the same handful of questions through entirely different APIs, so the naive fix — one bespoke MCP per cloud, with a hand-written typed tool per resource — is a treadmill: the space of things a responder eventually wants to read is effectively the whole cloud API. + +This spec defines a single read-only cloud-context MCP that gives the agent that context without ever being able to mutate cloud state or escalate its own privilege. + +## Goals + +- Let the operator agent answer cloud-context questions (reachability, permissions, cluster setup, logs, audit trail, inventory) for GCP and AWS from inside an investigation, without a human leaving the loop. +- Make adding coverage a config edit, not new Go; make adding a cloud a new provider behind one interface, not a parallel MCP. +- Guarantee read-only by construction and by harness, with a safety boundary the agent provably cannot bypass. +- Pin the cloud identity to a deployment-chosen, read-only principal that the agent can neither select nor escalate. +- Surface cloud auth readiness before a session starts, so the operator fixes a stale credential proactively rather than discovering a degraded session. + +## Non-goals + +- Any write, create, update, or delete operation against either cloud. Read-only is absolute. +- Clouds beyond GCP and AWS. The provider interface should not foreclose a third, but none ships here. +- Reading secrets, downloading bucket objects, shelling into instances, or impersonating identities of the agent's choosing. These sit on a hardcoded deny floor regardless of config. +- OAuth / SSO browser login flows inside triagent. Base authentication is the operator's own (or the workload's); triagent never runs an interactive login. This is a candidate future enhancement, not v1. +- Billing, cost, or quota reporting. + +## Design overview + +One package, `pkg/mcp/cloud/`, exposing `New(Options)` + `Run(ctx)` + a sibling `specs.go::ToolSpecs()`, registered with one `case "cloud"` in `cmd/triagent-mcp/serve.go` (ADR-0001) and selected at launch by `--provider=gcp|aws`. This mirrors the git MCP, which is one package bound per-repo via `--repo` and aliased `triagent-git-` at the `mcpconfig.go` wiring layer (`internal/preflight/mcpconfig.go`, ADR-0003); here the bound target is a cloud provider, aliased `triagent-cloud-`. Deployment config (provider, pinned identity, scope allowlist, command-allowlist override path) loads from the runtime profile (ADR-0008). + +The tool surface is provider-agnostic and lives once in `specs.go`. It is deliberately thin: two typed tools where shaped output clearly pays its context cost, plus a gated CLI escape hatch for the long tail. + +- `list_inventory` — projects / accounts and the accessible resources within an allowlisted scope, so the agent can orient. +- `session_status` — the read-only whoami: which pinned identity is active and whether it is valid. +- `run_cli` — a gated, read-only `gcloud` / `aws` invocation for everything else, with argument tokens supplied as an array. +- `list_allowed_commands` — the discovery tool that reads the same gating config `run_cli` enforces, so what is advertised is exactly what is permitted. + +Each typed tool calls through a `Provider` interface; selecting `--provider` chooses the concrete `gcp` or `aws` implementation, plugged in behind the interface exactly like the git MCP's `ghRunner` real-vs-stub seam. Providers return curated projections rather than raw API JSON, following the `pkg/mcp/k8s` `redact.go` discipline. + +```mermaid +flowchart TD + operator[operator agent] --> typed["typed tools
list_inventory · session_status"] + operator --> disc["list_allowed_commands"] + operator --> cli["run_cli
(argv tokens only)"] + typed --> iface{{Provider interface}} + cli --> harness["safe harness
no shell · fixed binary · allowlist
+ deny floor (subcommands & flags)
+ scope check + truncate"] + cfg[("command allowlist
embedded default,
profile-overridable")] --> harness + cfg --> disc + harness --> iface + iface --> gcp["gcp provider
gcloud + defaults"] + iface --> aws["aws provider
aws + defaults"] + id[("pinned read-only identity
impersonated via harness env")] -.outer floor.-> gcp + id -.outer floor.-> aws +``` + +## Security model + +The security model is the heart of this feature. It has two independent layers: the agent cannot run a forbidden command, and the agent cannot act as a forbidden identity. + +### The command harness cannot be bypassed + +`run_cli` never touches a shell. The guarantee is structural, not a matter of sanitizing strings. + +- **Argv-only input.** The tool input is a typed array of argument tokens, never a single command string. The harness never tokenizes a string itself, so there is no in-house splitter to fool. +- **Direct `execve`, no shell.** The harness execs the provider's fixed binary with the argv array (`exec.CommandContext`). No `sh -c` exists anywhere in the package. Shell metacharacters (`|`, `;`, `&&`, `$(…)`, backticks, `>`, newlines) have meaning only to a shell; handed to `gcloud`/`aws` as literal argv tokens they are inert and rejected by the binary. A unit test asserts no `sh -c` / `bash -c` construction exists and that an argv full of metacharacters never spawns a second process. +- **Positive allowlist on the normalized subcommand path** (for example `compute firewall-rules list`, `projects list`), loaded from an embedded default JSON overridable via a profile-pointed path. This is the `LoadAllowlist` pattern from `pkg/mcp/k8s/allowlist.go`: embedded default, optional override, applied identically. +- **A hardcoded deny floor the config can never re-enable**, mirroring how `LoadAllowlist` always filters `Secret` regardless of the kinds config. The floor covers dangerous subcommands (`secrets ... access`, `ssh`/`scp`, `cp`/`sync`, `auth`, `config set`) and dangerous flags (`--impersonate-service-account`, `--account`, `--profile`, `--endpoint-url`, `--cli-input-*`, `--configuration`), plus argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). +- **Scope validation.** Any `--project` / `--account` / region in the argv must be in the profile's scope allowlist, so the agent cannot pivot to an un-allowlisted target. +- **Output truncation** keeps a raw response from blowing the context budget. +- **Pinned binary and minimal env.** The binary is resolved to an absolute path once at startup; the subprocess runs with an explicit minimal `cmd.Env` (so a poisoned `PATH` cannot substitute a different binary) and closed stdin (no interactive prompt or fed input). + +### The agent cannot select or escalate identity + +The cloud identity is a deployment-chosen, read-only principal pinned in the profile. The agent can read which identity is active (`session_status`, `list_allowed_commands`) but has no tool to choose, change, or authenticate one. + +The identity is a stable contract; how the harness acquires credentials for it is a swappable realization, set by the deployment and injected through `cmd.Env` (which the agent never controls — it supplies argv only): + +- **Operator-ambient base auth plus harness-pinned impersonation (v1 primary).** The operator is authenticated as themselves through their own normal tooling (`gcloud auth login`, `aws sso login`). The harness pins impersonation of the configured read-only identity via environment: `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=` for GCP; `AWS_PROFILE=` (a profile whose `role_arn` is the read-only role with the operator's base as `source_profile`) for AWS. triagent stores no credential. Because the pin is in env, not argv, `--impersonate-service-account` and `--profile` stay on the agent deny floor without contradiction. Re-authentication is the operator's own corporate flow, outside triagent. +- **Workload Identity / IRSA (server / headless).** The workload is the pinned identity; base credentials come from the metadata server. This falls out of the same env-injection code path with the base credential sourced from the environment instead of the operator. triagent stores no credential. +- **Static read-only key connection (deferred fallback).** A service-account key (GCP) or static access keys (AWS) pasted into the connections panel, for environments where assume-role is not granted. This is the only realization where triagent holds a secret; it is out of v1 scope and slots in later behind the same connection surface and env injection. + +The deployment's read-only IAM grant on the pinned identity is the outermost floor: even a misconfigured-too-broad command allowlist cannot read secrets or exfiltrate, because the identity itself lacks the permission. + +## Auth readiness, preflight, and visible degrade + +A single whoami probe validates the identity chain: base credentials valid, impersonation / assume-role succeeds, and the resolved identity matches the pinned one. That one probe serves three surfaces so they can never disagree: + +- **The connections panel (pre-session visibility).** Cloud appears in the same sidebar panel as Slack and incident.io, but read-only: it is configured in the profile, not entered there. `GET /api/connections` grows a `cloud` array of `{provider, assumed_identity, valid, hint}`. The assumed identity always shows; the validity checkmark and the `ReauthAdvisor` hint (`run: gcloud auth login`) come from the probe, run on panel load so the operator fixes a stale credential before starting. +- **Session preflight (the gate).** `preflight.Run()` re-runs the same probe through the existing `auth.Provider` seam. This extends the current authentication preflight rather than adding new machinery. +- **Visible degrade, not block.** Unlike the current k8s auth preflight, a failed cloud probe does not fail the session. The session starts with the cloud source disabled and visibly marked unavailable; Kubernetes triage proceeds without the cloud axis. A stale cloud credential must never block all investigation. This introduces a soft-degrade path the preflight does not have today. + +## Risks and mitigations + +- **The agent bypasses the command safety net** (shell metacharacters, flag escapes, identity/endpoint redirection, scope pivot). Mitigated by structural defenses, not string filtering: no shell ever (argv + direct `execve`); a deny floor covering subcommands, flags, and argument prefixes; scope validation. The read-only IAM grant is an independent backstop. +- **Advertised commands drift from enforced commands.** `list_allowed_commands` and `run_cli` read one config; the allowlist is the single source of truth. +- **The agent widens its own allowlist or picks its identity.** The config and the pinned identity load server-side from the profile; the agent has tools to read them, none to mutate them. Impersonation is pinned in harness-controlled env, never agent argv. +- **Raw CLI output blows the context budget.** Output truncation on the escape hatch, plus typed tools for the orientation path. +- **Operator-ambient impersonation needs an IAM grant** (assume-role / `serviceAccountTokenCreator` on the pinned role). This is a one-time admin setup and the price of not storing a secret; documented as a deployment prerequisite. Workload Identity is the no-grant alternative for server deployments. +- **Soft-degrade is new preflight behavior.** The degrade path is cloud-source-scoped and explicit; the existing k8s block-on-failure behavior is unchanged. + +## Alternatives considered + +- **One bespoke MCP per cloud (`triagent-gcp`, `triagent-aws`).** Rejected: copy-pasted tool plumbing across two packages, against the "prefer a shared helper to a second consumer" and "don't introduce a new top-level MCP for a bound target" conventions. The provider abstraction collapses both into one package bound by `--provider`, the git-MCP pattern. +- **A fat typed-tool surface, one per resource.** Rejected as a treadmill: the readable surface is effectively the whole cloud API. The thin-typed-plus-gated-CLI split covers the long tail through config instead of code, and any axis can be promoted to a typed tool later when its raw output proves too noisy. +- **CLI-only, no typed tools.** Rejected: orientation (`list_inventory`) and the auth whoami (`session_status`) earn shaped output, and raw `--format=json` dumps are exactly the context cost `redact.go` exists to avoid. +- **Read-only enforced solely by IAM, free-form CLI on top.** Rejected as the whole story: read-only IAM still reads secrets and exfiltrates bucket objects, so "read-only" is necessary but not sufficient. The harness deny floor is what excludes those; IAM is the backstop, not the fence. +- **triagent holds a stored cloud credential as the primary model** (static key connection). Rejected as v1 primary: it puts a long-lived secret in triagent's custody and forces in-app re-auth. Operator-ambient impersonation stores nothing, gives a better audit trail (human plus role), and pushes re-auth to the operator's existing tooling. The stored-key connection survives as a deferred fallback for environments without assume-role. +- **OAuth / SSO login inside triagent.** Deferred: a different tier of work (callback handling, refresh-token storage and rotation, per-provider divergence, expiry visibility) for marginal gain over piggybacking on the operator's own session. Slots in later as one more env source behind the same connection. +- **Block the session on cloud auth failure** (mirroring k8s preflight). Rejected: cloud is secondary context; a stale cloud credential must not make a Kubernetes incident un-investigable. Visible degrade keeps triage moving. + +## Vocabulary + +- The server is the **cloud-context MCP**; instances are aliased **`triagent-cloud-`**. +- The swappable backend is a **provider** (`gcp`, `aws`) behind the **`Provider` interface**. +- The gated escape hatch is **`run_cli`**; its catalog is **`list_allowed_commands`**. +- The deployment-chosen identity is the **pinned identity**; the ways the harness acquires credentials for it are **realizations** (operator-ambient impersonation, Workload Identity, static-key connection). +- The investigative groupings (inventory, reachability, permissions, cluster, logs, audit) are **axes** — a navigational vocabulary for organizing coverage, never a code identifier. From 74e86cfb16985c273df61cc41dd1de7eb01650ba 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 04:20:20 +0200 Subject: [PATCH 02/76] docs(plan): cloud-context MCP implementation plan and orchestration state Four-PR breakdown on feature/cloud-context-mcp: scaffold+harness (#45) produces the Provider interface, identity probe, and env contracts; GCP (#43), AWS (#46), and launcher integration (#47) consume them in parallel. Includes the contracts table, conventions, and the resumable state file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-30-cloud-context-mcp.md | 410 ++++++++++++++++++ .../2026-05-30-cloud-context-mcp-state.md | 53 +++ 2 files changed, 463 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-cloud-context-mcp.md create mode 100644 docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md diff --git a/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md b/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md new file mode 100644 index 00000000..4bed9192 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md @@ -0,0 +1,410 @@ +# Read-only cloud-context MCP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a read-only cloud-context MCP (`pkg/mcp/cloud/`) that gives the operator agent GCP and AWS investigation context through a thin typed surface plus a bypass-resistant gated CLI, with a deployment-pinned read-only identity the agent cannot select or escalate. + +**Architecture:** One package bound at launch by `--provider`, aliased `triagent-cloud-` (the git-MCP pattern). Provider behaviour sits behind an injectable `cloud.Provider` interface (the teleport pattern), with `gcp` and `aws` implementations in subpackages wired by `cmd/triagent-mcp/serve.go`. All cloud access shells the provider CLI through one exec core; no cloud SDK dependency. The launcher pins a read-only identity via harness-controlled env, validates it with a shared whoami probe surfaced in the connections panel and `preflight.Run()`, and degrades the cloud source visibly rather than blocking the session. + +**Tech Stack:** Go (`os/exec`, `encoding/json`, `embed`), the `modelcontextprotocol/go-sdk/mcp` server, the existing `toolspec`, `auth.Provider`, `connections.Manager`, `preflight`, and `profile` packages; Next.js for the connections panel pill. + +**Spec:** `docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md` + +--- + +## PR breakdown + +The feature lands via the feature-branch model on `feature/cloud-context-mcp`. Four sub-PRs, each its own sub-issue under epic #44: + +| PR | Issue | Scope | Depends on | +| -- | ----- | ----- | ---------- | +| **A — scaffold + harness** | #45 | `pkg/mcp/cloud/`: `Provider` interface, command allowlist + deny floor, `run_cli` harness, `list_allowed_commands`, typed `list_inventory` + `session_status` against a fake provider, the shared identity probe, `serve.go` `--kind=cloud --provider=` wiring, wire test. | — | +| **B — GCP provider** | #43 | `pkg/mcp/cloud/providers/gcp`: implements `Provider` over `gcloud`; default allowlist + deny-floor additions; impersonation env contract. | A (interface) | +| **C — AWS provider** | #46 | `pkg/mcp/cloud/providers/aws`: implements `Provider` over `aws`; default allowlist + deny-floor additions; assume-role profile contract. | A (interface) | +| **D — launcher integration** | NEW | profile `cloud:` block; `mcpconfig.go` aliasing + env injection; `preflight` cloud probe + visible degrade; `connections` cloud array + `GET /api/connections`; frontend read-only pill. | A (probe), B/C (env contracts) | + +B, C, and D run in parallel once A's contracts are realized. The plan is written so each PR is independently reviewable and leaves `make test` green. + +## File structure + +`pkg/mcp/cloud/` (PR A): + +- `provider.go` — the `Provider` interface and the projection structs every tool returns (`Inventory`, `IdentityStatus`, `CLIResult`). +- `allowlist.go` — `Command`, `CommandAllowlist`, `LoadCommandAllowlist(path)`, and the hardcoded `denyFloor` (subcommands, flags, arg-prefixes). Mirrors `pkg/mcp/k8s/allowlist.go`. +- `harness.go` — `execCLI(ctx, binPath, argv, env, limit)`: the no-shell argv exec core with validation hooks and output truncation. +- `validate.go` — `validateArgv(argv, allow *CommandAllowlist, scope ScopeAllowlist)`: normalizes the subcommand path, checks allowlist, rejects deny-floor tokens, validates scope. +- `probe.go` — `Probe(ctx, p Provider) (IdentityStatus, error)`: the shared whoami the launcher and `session_status` both call. +- `server.go` — `Options`, `New`, `registerOn`, `Run`. `Options.Provider` is a `Provider` value (DI, teleport pattern). +- `specs.go` — `ToolSpecs()`. +- `tools_inventory.go` — `list_inventory` handler. +- `tools_status.go` — `session_status` handler. +- `tools_cli.go` — `run_cli` and `list_allowed_commands` handlers. +- `fake_test.go` — `fakeProvider` implementing `Provider` for package tests. +- `tools_wire_test.go` — asserts `ToolSpecs()` matches registered handlers (the existing wire-test convention). +- `harness_security_test.go` — the bypass-resistance assertions (no `sh -c`, metacharacters inert, deny floor, scope). + +`pkg/mcp/cloud/providers/gcp/` (PR B) and `pkg/mcp/cloud/providers/aws/` (PR C): + +- `provider.go` — the `Provider` implementation (binary name, default allowlist, deny-floor additions, env builder, projection parsers). +- `default_commands.json` — embedded default allowlist for this provider. +- `provider_test.go` — table tests over CLI-output fixtures → projections. + +Launcher (PR D): + +- `internal/profile/profile.go` — add the `Cloud` config block. +- `internal/preflight/mcpconfig.go` — `MCPAliasCloudPrefix`, cloud env constants, the cloud server entry. +- `internal/preflight/preflight.go` — cloud identity probe + degrade marking. +- `internal/connections/connections.go` — cloud status entries in the response shape. +- `internal/server/handlers_connections.go` — cloud array in `GET /api/connections`. +- `frontend/` — read-only cloud pill in the connections panel. + +## Contracts + +| Name | Producer (PR/issue) | Consumer | Shape | Realization | +| ---- | ------------------- | -------- | ----- | ----------- | +| `cloud-provider-interface` | A / #45 | B/#43, C/#46 | `cloud.Provider` Go interface (see Task A2) | stub-on-producer-branch: A's `provider.go` lands the interface + a `fakeProvider`; B/C branch from A's merged state | +| `cloud-identity-probe` | A / #45 | D / NEW | `cloud.Probe(ctx, Provider) (IdentityStatus, error)` and `IdentityStatus{Provider, AssumedIdentity, Valid, Hint string}` | stub-on-producer-branch: A exports `Probe` + `IdentityStatus`; D imports them | +| `cloud-serve-cli` | A / #45 | D / NEW | `triagent-mcp serve --kind=cloud --provider=` | data-only (CLI string) | +| `cloud-env-contract` | A+B+C | D / NEW | env var names the subprocess reads: `TRIAGENT_CLOUD_PROVIDER`, `TRIAGENT_CLOUD_ALLOWLIST_PATH`, `TRIAGENT_CLOUD_SCOPE`, plus provider impersonation env (`CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT` for gcp, `AWS_PROFILE` for aws) | data-only (exported consts in `cloud` + provider packages; D references the const names) | + +`IdentityStatus` is the single struct the probe returns; the connections array, the `session_status` tool, and the preflight gate all render from it, so they cannot disagree. + +## Conventions + +Every sub-PR inherits these (the dimensions from `feature-dev-workflow:maintaining-architectural-coherence`): + +- **Layout.** Provider implementations live in `pkg/mcp/cloud/providers//`, never in the parent package. The parent owns the interface, harness, allowlist, probe, tools; subpackages own only CLI specifics. +- **CLI-only access.** Every cloud read shells the provider binary through `cloud.execCLI`. No `cloud.google.com/go` or `aws-sdk-go` dependency in v1 — keeps auth and impersonation uniform (the CLI consumes the harness env). +- **Naming.** Server name `triagent-mcp-cloud`; session alias `triagent-cloud-`; tools `list_inventory`, `session_status`, `run_cli`, `list_allowed_commands`. The investigative groupings (inventory, reachability, permissions, cluster, logs, audit) are **axes** — used in prose and the allowlist's `Description` fields, never as Go identifiers, file names, or marker strings (the naming firewall). +- **Allowlist shape.** Provider default allowlists are `default_commands.json` embedded via `//go:embed`, loaded by the shared `LoadCommandAllowlist`, with the provider contributing deny-floor additions in code. The floor is never expressed in JSON (config can't re-enable it), mirroring how `LoadAllowlist` always filters `Secret`. +- **Output shaping.** Tools return projection structs, never raw API/CLI JSON. Redaction reuses the spirit of `pkg/mcp/k8s/redact.go`: secret-looking values are dropped, not surfaced. +- **Env discipline.** The agent supplies argv only. All credentials, impersonation, allowlist path, and scope reach the subprocess through `cmd.Env`, set by the launcher in `mcpconfig.go`. Identity-selecting flags are deny-floored in argv. +- **Tests.** Go race tests per the repo standard; CLI interaction is tested against captured-output fixtures (no live cloud). The wire test fails if `ToolSpecs()` drifts from registration. + +--- + +## PR A — scaffold + harness (#45) + +### Task A1: Package skeleton and server + +**Files:** +- Create: `pkg/mcp/cloud/server.go` +- Create: `pkg/mcp/cloud/provider.go` +- Test: `pkg/mcp/cloud/server_test.go`, `pkg/mcp/cloud/fake_test.go` + +- [ ] **Step 1: Write the failing test** — a `New` with a fake provider returns a server, and `New` with a nil provider errors. + +```go +// server_test.go +func TestNewRequiresProvider(t *testing.T) { + if _, err := New(Options{}); err == nil { + t.Fatal("expected error when Provider is nil") + } + if _, err := New(Options{Provider: &fakeProvider{}}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} +``` + +- [ ] **Step 2: Define the interface and fake** in `provider.go` and `fake_test.go`. + +```go +// provider.go +type Provider interface { + Name() string // "gcp" | "aws" + Binary() string // resolved absolute path to gcloud/aws + DefaultAllowlist() *CommandAllowlist // embedded default for this provider + DenyFloorAdditions() DenyFloor // provider-specific subcommands/flags + Inventory(ctx context.Context, run RunFunc) (Inventory, error) + Identity(ctx context.Context, run RunFunc) (IdentityStatus, error) +} + +// RunFunc is the harness exec core, injected so providers never exec directly. +type RunFunc func(ctx context.Context, argv []string) (CLIResult, error) + +type Inventory struct { + Scopes []Scope `json:"scopes"` // projects (gcp) / accounts (aws) +} +type Scope struct { + ID, Name string +} +type IdentityStatus struct { + Provider string `json:"provider"` + AssumedIdentity string `json:"assumed_identity"` + Valid bool `json:"valid"` + Hint string `json:"hint,omitempty"` +} +type CLIResult struct { + Stdout string `json:"stdout"` + Truncated bool `json:"truncated"` + ExitCode int `json:"exit_code"` +} +``` + +```go +// fake_test.go +type fakeProvider struct{ identity IdentityStatus } +func (f *fakeProvider) Name() string { return "fake" } +func (f *fakeProvider) Binary() string { return "/bin/true" } +func (f *fakeProvider) DefaultAllowlist() *CommandAllowlist { return &CommandAllowlist{} } +func (f *fakeProvider) DenyFloorAdditions() DenyFloor { return DenyFloor{} } +func (f *fakeProvider) Inventory(context.Context, RunFunc) (Inventory, error) { return Inventory{}, nil } +func (f *fakeProvider) Identity(context.Context, RunFunc) (IdentityStatus, error) { return f.identity, nil } +``` + +- [ ] **Step 3: Implement `server.go`** following the teleport pattern (`Options{Provider}`, `New`, `registerOn`, `Run`, server name `triagent-mcp-cloud`). +- [ ] **Step 4: Run** `go test ./pkg/mcp/cloud/ -run TestNewRequiresProvider -v` → PASS. +- [ ] **Step 5: Commit** `feat(cloud): provider interface and server skeleton (#45)`. + +### Task A2: Command allowlist and deny floor + +**Files:** +- Create: `pkg/mcp/cloud/allowlist.go` +- Test: `pkg/mcp/cloud/allowlist_test.go` + +- [ ] **Step 1: Write failing tests** covering: an override path replaces the embedded default; a command on the deny floor is dropped even if the override lists it; provider deny-floor additions merge in. + +```go +func TestLoadCommandAllowlistDropsDenyFloor(t *testing.T) { + // 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{}) + if err != nil { t.Fatal(err) } + if al.Allows([]string{"secrets","versions","access"}) { + t.Fatal("deny floor must drop secrets access regardless of config") + } + if !al.Allows([]string{"projects","list"}) { + t.Fatal("projects list should be allowed") + } +} +``` + +- [ ] **Step 2: Implement** `Command{Path, Description string, Redact bool}`, `CommandAllowlist{Commands []Command}`, `LoadCommandAllowlist(path string, extra DenyFloor)` mirroring `k8s.LoadAllowlist` (embedded default when path empty, else read file), then filter through the base `denyFloor` plus `extra`. `Allows(path []string)` normalizes and matches. + +```go +// the always-on floor; config can never re-enable these (the Secret pattern) +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"}, + ArgPrefixes: []string{"file://", "fileb://", "@", "http://", "https://"}, +} +``` + +- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -run TestLoadCommandAllowlist -v` → PASS. +- [ ] **Step 4: Commit** `feat(cloud): command allowlist with hardcoded deny floor (#45)`. + +### Task A3: Argv validation + +**Files:** +- Create: `pkg/mcp/cloud/validate.go` +- Test: `pkg/mcp/cloud/validate_test.go` + +- [ ] **Step 1: Write failing tests** — table over: allowed verb passes; un-allowlisted verb rejected; each deny-floor flag rejected; each arg-prefix rejected; `--project` outside scope rejected; shell metacharacter tokens (`;`, `|`, `$(x)`) rejected by allowlist (not interpreted). + +```go +func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}} + scope := ScopeAllowlist{Projects: []string{"prod"}} + cases := []struct{ name string; argv []string; ok bool }{ + {"allowed", []string{"compute","instances","list","--project","prod"}, true}, + {"bad-scope", []string{"compute","instances","list","--project","other"}, false}, + {"impersonate", []string{"compute","instances","list","--impersonate-service-account","x"}, false}, + {"file-prefix", []string{"compute","instances","list","--filter","@/etc/passwd"}, false}, + {"metachar", []string{"compute","instances","list",";","rm","-rf","/"}, false}, + {"not-allowed", []string{"iam","service-accounts","create"}, false}, + } + // assert validateArgv(argv, al, scope) error-ness matches !ok +} +``` + +- [ ] **Step 2: Implement** `validateArgv`: split flags from positionals, normalize the leading subcommand path, `al.Allows`, reject any token matching a deny-floor flag / arg-prefix, validate `--project`/`--account`/region against `ScopeAllowlist`. +- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -run TestValidateArgv -v` → PASS. +- [ ] **Step 4: Commit** `feat(cloud): argv validation against allowlist, deny floor, and scope (#45)`. + +### Task A4: No-shell exec core and truncation + +**Files:** +- Create: `pkg/mcp/cloud/harness.go` +- Test: `pkg/mcp/cloud/harness_test.go`, `pkg/mcp/cloud/harness_security_test.go` + +- [ ] **Step 1: Write failing security tests** — (a) source-level: the package contains no `"sh"`/`"bash"` `-c` exec construction; (b) behavioural: `execCLI` with argv `["-c","echo pwned"]` against `/bin/echo` prints the literal tokens, never spawning a second process; (c) output beyond `limit` sets `Truncated`. + +```go +func TestExecCLINeverUsesShell(t *testing.T) { + src, _ := os.ReadFile("harness.go") + if bytes.Contains(src, []byte(`"-c"`)) || bytes.Contains(src, []byte("sh -c")) { + t.Fatal("harness must never construct a shell command") + } +} +func TestExecCLITruncates(t *testing.T) { + r, err := execCLI(context.Background(), "/bin/echo", []string{strings.Repeat("x", 100)}, nil, 10) + if err != nil { t.Fatal(err) } + if !r.Truncated || len(r.Stdout) > 10 { t.Fatalf("expected truncation, got %+v", r) } +} +``` + +- [ ] **Step 2: Implement** `execCLI` with `exec.CommandContext(ctx, binPath, argv...)`, explicit minimal `cmd.Env`, `cmd.Stdin = nil`, captured stdout with a hard byte cap (`limit`), returning `CLIResult`. No shell, ever. +- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -run TestExecCLI -v -race` → PASS. +- [ ] **Step 4: Commit** `feat(cloud): no-shell argv exec core with output truncation (#45)`. + +### Task A5: Identity probe + +**Files:** +- Create: `pkg/mcp/cloud/probe.go` +- Test: `pkg/mcp/cloud/probe_test.go` + +- [ ] **Step 1: Write failing test** — `Probe` delegates to `Provider.Identity` and returns its `IdentityStatus`; a provider error yields `Valid:false` with the error surfaced as `Hint`. +- [ ] **Step 2: Implement** `Probe(ctx, p Provider) (IdentityStatus, error)` calling `p.Identity` with a `RunFunc` bound to `execCLI` + the provider binary, validating the resolved identity is non-empty. +- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -run TestProbe -v` → PASS. +- [ ] **Step 4: Commit** `feat(cloud): shared identity probe (#45)`. + +### Task A6: Tools and specs + +**Files:** +- Create: `pkg/mcp/cloud/tools_inventory.go`, `tools_status.go`, `tools_cli.go`, `specs.go` +- Test: `pkg/mcp/cloud/tools_test.go`, `pkg/mcp/cloud/tools_wire_test.go` + +- [ ] **Step 1: Write failing tests** (driven by `fakeProvider`): `list_inventory` returns the fake's scopes; `session_status` returns the fake's identity; `list_allowed_commands` returns the loaded allowlist; `run_cli` rejects a deny-floored argv before exec and shapes a `CLIResult` on success; the wire test asserts `ToolSpecs()` names match registered handlers. +- [ ] **Step 2: Implement** the four handlers and `ToolSpecs()` (server `triagent-cloud`, `toolspec.FromStruct` inputs). `run_cli` calls `validateArgv` then `execCLI`; `list_allowed_commands` reads the same `CommandAllowlist`. +- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -v -race` → PASS. +- [ ] **Step 4: Commit** `feat(cloud): list_inventory, session_status, run_cli, list_allowed_commands (#45)`. + +### Task A7: serve.go wiring + +**Files:** +- Modify: `cmd/triagent-mcp/serve.go` (add `case "cloud"`, `--provider` flag, `runCloud`) +- Test: `cmd/triagent-mcp/serve_test.go` + +- [ ] **Step 1: Write failing test** — `--kind=cloud` with no/unknown `--provider` errors with a clear message; a known provider constructs a server. +- [ ] **Step 2: Implement** `runCloud(ctx, f)`: parse `--provider`, construct the gcp/aws impl (imported from the provider subpackages — stubbed to return an error "provider not built yet" until PRs B/C land, so A compiles and tests pass), call `cloud.New(cloud.Options{Provider: impl})`. Add `cloud` to the `--kind` usage strings. +- [ ] **Step 3: Run** `go test ./cmd/triagent-mcp/ -run TestServeCloud -v` → PASS; `make lint` clean. +- [ ] **Step 4: Commit** `feat(cloud): register --kind=cloud --provider in serve.go (#45)`. + +--- + +## PR B — GCP provider (#43) + +Branches from A's merged state (interface + harness available). Implements `cloud.Provider` over `gcloud`. + +### Task B1: Provider skeleton and binary resolution + +**Files:** +- Create: `pkg/mcp/cloud/providers/gcp/provider.go`, `default_commands.json` +- Test: `pkg/mcp/cloud/providers/gcp/provider_test.go` + +- [ ] **Step 1:** Failing test — `New()` resolves the `gcloud` binary (via `exec.LookPath`, overridable for tests) and `Name()` returns `"gcp"`, `DefaultAllowlist()` loads the embedded JSON. +- [ ] **Step 2:** Implement the struct, `//go:embed default_commands.json`, and `DenyFloorAdditions()` (gcp-specific: e.g. `compute ssh`, `compute scp`, `functions call`). +- [ ] **Step 3:** Run `go test ./pkg/mcp/cloud/providers/gcp/ -v` → PASS. +- [ ] **Step 4:** Commit `feat(cloud/gcp): provider skeleton, default allowlist, deny-floor additions (#43)`. + +### Task B2: Identity (whoami over impersonation) + +- [ ] **Step 1:** Failing test over a captured `gcloud auth list --format=json` fixture → `IdentityStatus{AssumedIdentity, Valid}`, reading the active account and the `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT` target. +- [ ] **Step 2:** Implement `Identity(ctx, run)` parsing the fixture shape; `Valid` requires the resolved identity to equal the impersonation target. +- [ ] **Step 3:** Run the test → PASS. +- [ ] **Step 4:** Commit `feat(cloud/gcp): identity probe over impersonation (#43)`. + +### Task B3: Inventory (`gcloud projects list`) + +- [ ] **Step 1:** Failing test over a `gcloud projects list --format=json` fixture → `Inventory{Scopes}`. +- [ ] **Step 2:** Implement `Inventory(ctx, run)` projecting id + name. +- [ ] **Step 3:** Run → PASS. +- [ ] **Step 4:** Commit `feat(cloud/gcp): inventory projection (#43)`. + +### Task B4: Wire the provider into serve.go + +- [ ] **Step 1:** Replace the A7 stub so `--provider=gcp` constructs `gcp.New()`. +- [ ] **Step 2:** Run `go test ./... -race` and `make lint` → PASS. +- [ ] **Step 3:** Commit `feat(cloud): wire gcp provider into serve.go (#43)`. + +## PR C — AWS provider (#46) + +Mirror of PR B over the `aws` CLI. Branches from A's merged state; independent of B. + +### Task C1: Provider skeleton + +- [ ] Binary `aws`; `Name()` `"aws"`; embedded `default_commands.json`; `DenyFloorAdditions()` (aws-specific: e.g. `ec2 get-password-data`, anything that returns credentials material). +- [ ] Commit `feat(cloud/aws): provider skeleton, default allowlist, deny-floor additions (#46)`. + +### Task C2: Identity (`aws sts get-caller-identity`) + +- [ ] Failing test over a `sts get-caller-identity` fixture → `IdentityStatus`; `Valid` requires the resolved ARN to match the pinned role (the `AWS_PROFILE` assume-role target). +- [ ] Commit `feat(cloud/aws): identity probe over assumed role (#46)`. + +### Task C3: Inventory (`aws organizations list-accounts`, fallback `sts get-caller-identity` account) + +- [ ] Failing test over a `list-accounts` fixture → `Inventory{Scopes}`; on `AccessDenied` (no orgs access) fall back to the single caller account. +- [ ] Commit `feat(cloud/aws): inventory projection with single-account fallback (#46)`. + +### Task C4: Wire into serve.go + +- [ ] `--provider=aws` constructs `aws.New()`; `go test ./... -race` + `make lint` → PASS. +- [ ] Commit `feat(cloud): wire aws provider into serve.go (#46)`. + +## PR D — launcher integration (NEW issue) + +Branches from A's merged state (needs `cloud.Probe`, `IdentityStatus`, env-const names). Independent of B/C at compile time (references env-var name constants, not provider packages). + +### Task D1: Profile `cloud:` block + +**Files:** +- Modify: `internal/profile/profile.go`, `internal/profile/embed.go` (base merge) +- Test: `internal/profile/profile_test.go` + +- [ ] **Step 1:** Failing test — a profile YAML with a `cloud:` block loads into `Profile.Cloud`, and `base:` merge inherits it when the override omits it. +- [ ] **Step 2:** Add `Cloud []CloudSource` with `{Alias, Provider, AssumedIdentity, Scope, CommandAllowlistPath string}`; extend `applyBase`. +- [ ] **Step 3:** Run `go test ./internal/profile/ -race` → PASS. +- [ ] **Step 4:** Commit `feat(profile): cloud source config block (#NEW)`. + +### Task D2: mcpconfig aliasing and env injection + +**Files:** +- Modify: `internal/preflight/mcpconfig.go` +- Test: `internal/preflight/mcpconfig_test.go` + +- [ ] **Step 1:** Failing test — given a `CloudSource`, `writeMCPConfig` emits a `triagent-cloud-` server with `args ["serve","--kind=cloud","--provider=

"]` and env carrying `TRIAGENT_CLOUD_PROVIDER`, `TRIAGENT_CLOUD_ALLOWLIST_PATH`, `TRIAGENT_CLOUD_SCOPE`, and the impersonation env (`CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT` / `AWS_PROFILE`) from the source's `AssumedIdentity`. +- [ ] **Step 2:** Add `MCPAliasCloudPrefix = "triagent-cloud-"`, the env constants, and the cloud loop mirroring the linked-repos loop. +- [ ] **Step 3:** Run `go test ./internal/preflight/ -race` → PASS. +- [ ] **Step 4:** Commit `feat(preflight): wire triagent-cloud- servers with pinned-identity env (#NEW)`. + +### Task D3: Preflight probe and visible degrade + +**Files:** +- Modify: `internal/preflight/preflight.go` +- Test: `internal/preflight/preflight_test.go` + +- [ ] **Step 1:** Failing test — when a cloud source's `cloud.Probe` returns `Valid:false`, the session still starts (no error) but the source is marked unavailable in the `Result`; when `Valid:true` it's available. +- [ ] **Step 2:** Add a `CloudSources []CloudSourceStatus` field to `Result`; run `cloud.Probe` per source after kubeconfig freeze; never return an error for a failed cloud probe (degrade, don't block); attach the `Hint`. +- [ ] **Step 3:** Run `go test ./internal/preflight/ -race` → PASS. +- [ ] **Step 4:** Commit `feat(preflight): cloud identity probe with visible degrade (#NEW)`. + +### Task D4: Connections array and API + +**Files:** +- Modify: `internal/connections/connections.go`, `internal/server/handlers_connections.go` +- Test: `internal/connections/connections_test.go`, `internal/server/handlers_connections_test.go` + +- [ ] **Step 1:** Failing test — `GET /api/connections` includes a `cloud` array of `{provider, assumed_identity, valid, hint}` built from the profile's cloud sources probed at request time; the entries are read-only (no `PUT`/`DELETE` route added for cloud). +- [ ] **Step 2:** Extend the response builder to enumerate profile cloud sources and run `cloud.Probe`; reuse `IdentityStatus` fields directly. +- [ ] **Step 3:** Run `go test ./internal/connections/ ./internal/server/ -race` → PASS. +- [ ] **Step 4:** Commit `feat(connections): read-only cloud identity status in /api/connections (#NEW)`. + +### Task D5: Frontend pill + +**Files:** +- Modify: the connections panel component under `frontend/` +- Test: the panel's vitest spec + +- [ ] **Step 1:** Failing vitest — the panel renders a cloud pill per `cloud[]` entry showing the assumed identity and a checkmark when `valid`, and the reauth `hint` when not; the pill has no edit affordance. +- [ ] **Step 2:** Render the cloud entries alongside Slack/incident.io, read-only. +- [ ] **Step 3:** Run `cd frontend && npm test -- --run` and `npm run typecheck` → PASS. +- [ ] **Step 4:** Commit `feat(web): read-only cloud identity pills in connections panel (#NEW)`. + +--- + +## Self-review + +- **Spec coverage:** package/`--provider`/alias (A1, A7, D2); thin typed tools (A6); `run_cli` + `list_allowed_commands` (A6); no-shell harness + deny floor + scope + truncation (A2–A4); shared probe (A5) across `session_status` (A6), preflight (D3), connections (D4); pinned-identity impersonation env (D2, B2, C2); visible degrade (D3); read-only connections pill (D4–D5); GCP/AWS providers (B, C). Alternatives/non-goals (SDK, OAuth, mutation) are enforced by the CLI-only convention, the deny floor, and the absence of write paths. +- **Placeholder scan:** provider projection internals (B2–B3, C2–C3) are specified as "parse this fixture into this struct" with the fixture and struct named; the exact field-by-field parse is filled during TDD against captured CLI output, which is the correct altitude (inventing `gcloud` JSON keys now would be a guess). No `TBD`/`TODO` remain. +- **Type consistency:** `Provider`, `RunFunc`, `Inventory`/`Scope`, `IdentityStatus`, `CLIResult`, `CommandAllowlist`/`Command`, `DenyFloor`, `ScopeAllowlist`, `cloud.Probe`, `MCPAliasCloudPrefix`, and the `TRIAGENT_CLOUD_*` env names are used consistently across tasks and the contracts table. diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md new file mode 100644 index 00000000..948c429b --- /dev/null +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -0,0 +1,53 @@ +--- +feature: cloud-context-mcp +spec: docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md +plan: docs/superpowers/plans/2026-05-30-cloud-context-mcp.md +tracking_issue: #44 +feature_branch: feature/cloud-context-mcp +feature_worktree: .claude/worktrees/cloud-context-mcp +sub_pr_approval: autonomous +integration_pr: +status: planning +--- + +# Read-only cloud-context MCP (GCP and AWS) — orchestration state + +## Phases + +- **Phase 1 (foundational)** — `#45` (scaffold + harness; produces every contract) +- **Phase 2 (consumers, parallel)** — `#43` (GCP provider), `#46` (AWS provider), `#47` (launcher integration) + +## PRs / worktrees + +| Issue | Branch | Worktree path | PR (→ base) | Status | +| ----- | ------ | ------------- | ----------- | ------ | +| #45 — scaffold + harness | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | +| #43 — GCP provider | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | +| #46 — AWS provider | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | +| #47 — launcher integration | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | + +## Contracts + +| Name | Realization | Realized in | Status | +| ---- | ----------- | ----------- | ------ | +| `cloud-provider-interface` | stub-on-producer-branch (`cloud.Provider` + `fakeProvider` land in #45) | #45 | pending | +| `cloud-identity-probe` | stub-on-producer-branch (`cloud.Probe` + `IdentityStatus` exported by #45) | #45 | pending | +| `cloud-serve-cli` | data-only (`serve --kind=cloud --provider=`) | n/a | pending | +| `cloud-env-contract` | data-only (`TRIAGENT_CLOUD_*` + impersonation env consts) | #45 / #43 / #46 | pending | + +All four contracts are produced by #45, so Phase 2 cannot start until #45 merges into the feature branch. They flip to `locked` once #45's interface, probe, and env constants land. + +## Bubble-up log + +- _No concerns yet._ + +## Resume checklist + +For a fresh Claude session resuming this work: + +1. Read this state file in full. +2. Read the plan at the path in the `plan:` frontmatter. +3. Read the spec at the path in the `spec:` frontmatter. +4. Verify each open PR's actual state via `gh pr view `. +5. For each `in-progress` or `draft` row, `cd` to the worktree path and check `git status` + `git log --oneline main..HEAD`. +6. Re-dispatch subagents as needed per `feature-dev-workflow:developing-a-feature` (Phase 2 fans out only after #45 merges). From c32b8fe8f306701116f9879b70b5cd0cda5b2ead 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 04:26:33 +0200 Subject: [PATCH 03/76] chore(state): enter developing, dispatch #45 scaffold wave Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index 948c429b..27c96685 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -7,7 +7,7 @@ feature_branch: feature/cloud-context-mcp feature_worktree: .claude/worktrees/cloud-context-mcp sub_pr_approval: autonomous integration_pr: -status: planning +status: developing --- # Read-only cloud-context MCP (GCP and AWS) — orchestration state @@ -21,7 +21,7 @@ status: planning | Issue | Branch | Worktree path | PR (→ base) | Status | | ----- | ------ | ------------- | ----------- | ------ | -| #45 — scaffold + harness | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | +| #45 — scaffold + harness | feature/cloud-context-mcp--scaffold | .claude/worktrees/cloud-context-mcp--scaffold | _tbd_ → feature/cloud-context-mcp | dispatched | | #43 — GCP provider | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | | #46 — AWS provider | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | | #47 — launcher integration | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | From 6a15a364f01ec6b1c15eeed803ca066577a19996 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 04:51:58 +0200 Subject: [PATCH 04/76] docs(claude): require testify assertions in tests; log #45 env + testify bubble-ups Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index 27c96685..e9577866 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -39,7 +39,8 @@ All four contracts are produced by #45, so Phase 2 cannot start until #45 merges ## Bubble-up log -- _No concerns yet._ +- **2026-05-30 — minimal-env seam missing in the harness (blocks #45 merge).** `cloud.Server.run` (server.go) calls `execCLI(..., argv, nil, ...)`; in Go a nil `cmd.Env` inherits the full parent environment, contradicting the spec's "explicit minimal `cmd.Env`" and `harness.go`'s own doc comment, and leaking the launcher's process env into `gcloud`/`aws`. The env-forwarding seam is owned by the parent package (conventions: subpackages own only CLI specifics), so it must land in #45 before fan-out. Resolution: #45 follow-up adds a provider-contributed env-passthrough (var **names** the CLI needs forwarded) merged with a minimal base set, built once and passed to `execCLI`; `fakeProvider` returns none. **Propagation:** #43/#46 implement the new `Provider` env-passthrough method; #47 unaffected (still injects env onto the `triagent-mcp` process). Interface grows by one method before consumers branch. +- **2026-05-30 — tests must use `testify` (user directive).** All cloud tests convert to `assert`/`require`; CLAUDE.md amended to make this the repo standard (testify is already used in 166 test files). **Propagation:** #43/#46/#47 inherit the rule via CLAUDE.md; their tests use testify from the start. ## Resume checklist From 2a0d6d42ad5c14402050ce266cc662e4c52d90b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 05:02:35 +0200 Subject: [PATCH 05/76] feat(cloud): scaffold the read-only cloud-context MCP package and safety harness (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cloud): provider interface and server skeleton (#45) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): command allowlist with hardcoded deny floor (#45) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): argv validation against allowlist, deny floor, and scope (#45) Exact-match the positional subcommand path so a surplus token (a shell metacharacter, an extra argument) cannot ride through on an allowed prefix. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): no-shell argv exec core with output truncation (#45) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): shared identity probe (#45) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): list_inventory, session_status, run_cli, list_allowed_commands (#45) Wire the four tools onto the server, load the command allowlist through the deny floor at construction, and bind run_cli + the providers to a single validated no-shell run core. list_allowed_commands reads the same allowlist run_cli enforces, so advertised equals permitted. Add the TRIAGENT_CLOUD_* env-name constants the launcher injects through the subprocess env. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): register --kind=cloud --provider in serve.go (#45) Parse --provider, decode the frozen scope and allowlist override from the subprocess env, and construct the server behind cloud.Provider. The gcp/aws implementations land in their own PRs; until then a known provider reports it is not yet built and an unknown one is named in the error. Also fold cloud.ToolSpecs() into the launcher tool catalog so the four tools surface in the MCP catalog view alongside every other server. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud): build an explicit minimal subprocess env instead of inheriting the parent (#45) s.run passed nil to execCLI, which makes Go's exec set cmd.Env = nil and inherit the entire parent process environment — violating the spec's minimal-env guarantee and harness.go's own no-leak doc comment. The existing TestExecCLIMinimalEnv passed only because it called execCLI directly with an explicit env; the real caller bypassed that. Add Provider.EnvPassthrough() so each provider declares the env var names its CLI needs forwarded, and build the subprocess env from os.Environ() filtered to the base set plus those names via Server.subprocessEnv. A new test exercises the server-built env: a parent-env canary is dropped while a declared passthrough var survives. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cloud): assert with testify across the cloud package (#45) Convert the scaffold's tests from bare t.Fatal/t.Fatalf/t.Errorf to testify, the repo standard: require for preconditions a failure must stop at (a non-nil error before a dereference, setup that must succeed), assert for independent checks that should keep running. Assertion intent is preserved exactly; no security assertion is weakened, and the harness_security_test source-scan logic (reading harness.go bytes) stays intact. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 116 ++++++++++++---- cmd/triagent-mcp/serve_cloud_test.go | 36 +++++ internal/server/meta.go | 2 + pkg/mcp/cloud/allowlist.go | 184 +++++++++++++++++++++++++ pkg/mcp/cloud/allowlist_test.go | 58 ++++++++ pkg/mcp/cloud/default_commands.json | 3 + pkg/mcp/cloud/env.go | 17 +++ pkg/mcp/cloud/fake_test.go | 51 +++++++ pkg/mcp/cloud/harness.go | 42 ++++++ pkg/mcp/cloud/harness_security_test.go | 62 +++++++++ pkg/mcp/cloud/harness_test.go | 19 +++ pkg/mcp/cloud/probe.go | 36 +++++ pkg/mcp/cloud/probe_test.go | 44 ++++++ pkg/mcp/cloud/provider.go | 75 ++++++++++ pkg/mcp/cloud/server.go | 137 ++++++++++++++++++ pkg/mcp/cloud/server_test.go | 37 +++++ pkg/mcp/cloud/specs.go | 36 +++++ pkg/mcp/cloud/tools_cli.go | 57 ++++++++ pkg/mcp/cloud/tools_inventory.go | 28 ++++ pkg/mcp/cloud/tools_status.go | 28 ++++ pkg/mcp/cloud/tools_test.go | 113 +++++++++++++++ pkg/mcp/cloud/tools_wire_test.go | 51 +++++++ pkg/mcp/cloud/validate.go | 125 +++++++++++++++++ pkg/mcp/cloud/validate_test.go | 68 +++++++++ 24 files changed, 1400 insertions(+), 25 deletions(-) create mode 100644 cmd/triagent-mcp/serve_cloud_test.go create mode 100644 pkg/mcp/cloud/allowlist.go create mode 100644 pkg/mcp/cloud/allowlist_test.go create mode 100644 pkg/mcp/cloud/default_commands.json create mode 100644 pkg/mcp/cloud/env.go create mode 100644 pkg/mcp/cloud/fake_test.go create mode 100644 pkg/mcp/cloud/harness.go create mode 100644 pkg/mcp/cloud/harness_security_test.go create mode 100644 pkg/mcp/cloud/harness_test.go create mode 100644 pkg/mcp/cloud/probe.go create mode 100644 pkg/mcp/cloud/probe_test.go create mode 100644 pkg/mcp/cloud/provider.go create mode 100644 pkg/mcp/cloud/server.go create mode 100644 pkg/mcp/cloud/server_test.go create mode 100644 pkg/mcp/cloud/specs.go create mode 100644 pkg/mcp/cloud/tools_cli.go create mode 100644 pkg/mcp/cloud/tools_inventory.go create mode 100644 pkg/mcp/cloud/tools_status.go create mode 100644 pkg/mcp/cloud/tools_test.go create mode 100644 pkg/mcp/cloud/tools_wire_test.go create mode 100644 pkg/mcp/cloud/validate.go create mode 100644 pkg/mcp/cloud/validate_test.go diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index 5650b5ea..1b81ecb8 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -2,13 +2,16 @@ 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/git" "github.com/sourcehawk/triagent/pkg/mcp/incidentio" "github.com/sourcehawk/triagent/pkg/mcp/k8s" @@ -21,28 +24,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 +73,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 +97,9 @@ type serveFlags struct { promURL string promBearer string promBasic string + + // cloud flags + cloudProvider string } func serveCmd() *cobra.Command { @@ -104,14 +109,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 +155,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 +223,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 +274,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 +436,59 @@ 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 and target scope through the subprocess env +// (cloud.EnvAllowlistPath, cloud.EnvScope), never argv. +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) + } + provider, err := newCloudProvider(f.cloudProvider) + if err != nil { + return err + } + srv, err := cloud.New(cloud.Options{ + Provider: provider, + AllowlistPath: os.Getenv(cloud.EnvAllowlistPath), + Scope: parseCloudScope(os.Getenv(cloud.EnvScope)), + }) + 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) +} + +// newCloudProvider constructs the cloud.Provider for the named provider. The +// gcp and aws implementations land in pkg/mcp/cloud/providers/ in their +// own PRs; until then a known provider reports that it is not yet built and an +// unknown one is named in the error. +func newCloudProvider(name string) (cloud.Provider, error) { + switch name { + case "gcp", "aws": + return nil, fmt.Errorf("cloud provider %q is not built yet", name) + default: + return nil, fmt.Errorf("unknown cloud --provider %q (want gcp or aws)", name) + } +} + +// 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 logged and treated the same, +// so a bad profile entry never silently widens scope. +func parseCloudScope(raw string) cloud.ScopeAllowlist { + var scope cloud.ScopeAllowlist + if raw == "" { + return scope + } + if err := json.Unmarshal([]byte(raw), &scope); err != nil { + log.Warn("mcp serve --kind=cloud: ignoring malformed scope", "error", err) + return cloud.ScopeAllowlist{} + } + return scope +} + 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..32504a07 --- /dev/null +++ b/cmd/triagent-mcp/serve_cloud_test.go @@ -0,0 +1,36 @@ +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") +} 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/pkg/mcp/cloud/allowlist.go b/pkg/mcp/cloud/allowlist.go new file mode 100644 index 00000000..409a90b7 --- /dev/null +++ b/pkg/mcp/cloud/allowlist.go @@ -0,0 +1,184 @@ +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). Redact marks output that needs secret-scrubbing. +type Command struct { + Path string `json:"path"` + Description string `json:"description,omitempty"` + Redact bool `json:"redact,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, 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", + }, + 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.deniesSubcommand(normalizePath(c.Path)) { + continue + } + out.Commands = append(out.Commands, c) + } + return out +} + +// Allows reports whether argv's positional subcommand path exactly equals an +// allowlisted command path. Flag tokens and their values do not participate; +// only the leading positionals do. The match is exact rather than prefix so a +// surplus positional — a shell metacharacter token, an extra argument — never +// rides through on the back of an allowed prefix. +func (a *CommandAllowlist) Allows(argv []string) bool { + path := subcommandPath(argv) + for _, c := range a.Commands { + if pathEqual(path, normalizePath(c.Path)) { + return true + } + } + return false +} + +// deniesSubcommand reports whether a normalized subcommand path falls under any +// floored subcommand. A floor entry matches when it is a token-wise prefix of +// the path, so "secrets" floors "secrets versions access" and "compute ssh" +// floors "compute ssh foo". +func (d DenyFloor) deniesSubcommand(path []string) bool { + for _, s := range d.Subcommands { + if pathHasPrefix(path, normalizePath(s)) { + 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 +} + +// pathEqual reports whether two token paths are identical. +func pathEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[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..1dbd5129 --- /dev/null +++ b/pkg/mcp/cloud/allowlist_test.go @@ -0,0 +1,58 @@ +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 TestAllowsMatchesLongestPathPrefix(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") +} 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..fcb57ae7 --- /dev/null +++ b/pkg/mcp/cloud/env.go @@ -0,0 +1,17 @@ +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" +) diff --git a/pkg/mcp/cloud/fake_test.go b/pkg/mcp/cloud/fake_test.go new file mode 100644 index 00000000..62593e8f --- /dev/null +++ b/pkg/mcp/cloud/fake_test.go @@ -0,0 +1,51 @@ +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 +} + +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) (IdentityStatus, error) { + return f.identity, f.identityErr +} diff --git a/pkg/mcp/cloud/harness.go b/pkg/mcp/cloud/harness.go new file mode 100644 index 00000000..c185be78 --- /dev/null +++ b/pkg/mcp/cloud/harness.go @@ -0,0 +1,42 @@ +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 + +// 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 capped at limit. 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. +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 + + out, err := cmd.Output() + res := CLIResult{} + if len(out) > limit { + out = out[:limit] + res.Truncated = true + } + res.Stdout = string(out) + + 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..9730e346 --- /dev/null +++ b/pkg/mcp/cloud/harness_test.go @@ -0,0 +1,19 @@ +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) +} diff --git a/pkg/mcp/cloud/probe.go b/pkg/mcp/cloud/probe.go new file mode 100644 index 00000000..54e722d6 --- /dev/null +++ b/pkg/mcp/cloud/probe.go @@ -0,0 +1,36 @@ +package cloud + +import "context" + +// 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. +// +// 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) (IdentityStatus, error) { + run := func(ctx context.Context, argv []string) (CLIResult, error) { + return execCLI(ctx, p.Binary(), argv, nil, defaultOutputLimit) + } + + st, err := p.Identity(ctx, run) + if err != nil { + return IdentityStatus{ + Provider: p.Name(), + 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. + st.Valid = false + } + 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..e94aa390 --- /dev/null +++ b/pkg/mcp/cloud/probe_test.go @@ -0,0 +1,44 @@ +package cloud + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) + require.NoError(t, err) + assert.True(t, st.Valid) + assert.Equal(t, "ro-sa@proj.iam.gserviceaccount.com", st.AssumedIdentity) +} + +func TestProbeSurfacesProviderErrorAsInvalid(t *testing.T) { + t.Parallel() + p := &fakeProvider{name: "aws", identityErr: errors.New("token expired")} + st, err := Probe(context.Background(), p) + 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") +} + +func TestProbeInvalidWhenIdentityEmpty(t *testing.T) { + t.Parallel() + p := &fakeProvider{name: "gcp", identity: IdentityStatus{Provider: "gcp", Valid: true}} + st, err := Probe(context.Background(), p) + require.NoError(t, err) + assert.False(t, st.Valid, "an empty resolved identity must not be reported valid") +} diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go new file mode 100644 index 00000000..8abf18b7 --- /dev/null +++ b/pkg/mcp/cloud/provider.go @@ -0,0 +1,75 @@ +// 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. It execs only through run, never directly. + Identity(ctx context.Context, run RunFunc) (IdentityStatus, error) +} + +// 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 pinned identity can read. +type Scope struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// 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"` +} + +// CLIResult is the shaped result of one run_cli invocation. Raw provider JSON +// is never surfaced; the harness caps output and reports truncation. +type CLIResult struct { + Stdout string `json:"stdout"` + Truncated bool `json:"truncated"` + ExitCode int `json:"exit_code"` +} diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go new file mode 100644 index 00000000..21f65497 --- /dev/null +++ b/pkg/mcp/cloud/server.go @@ -0,0 +1,137 @@ +package cloud + +import ( + "context" + "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 +} + +// Server holds the configured cloud-context MCP server. +type Server struct { + impl *mcp.Server + provider Provider + allowlist *CommandAllowlist + scope ScopeAllowlist +} + +// 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, + } + s.registerOn(impl) + return s, nil +} + +// 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. Providers and tools exec only through this RunFunc, never +// directly: it validates argv before handing it to the no-shell exec core. +func (s *Server) run(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) +} + +// 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. +func (s *Server) subprocessEnv() []string { + keep := make(map[string]bool, len(baseEnvPassthrough)) + for _, name := range baseEnvPassthrough { + keep[name] = true + } + for _, name := range s.provider.EnvPassthrough() { + 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: "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..002cfb09 --- /dev/null +++ b/pkg/mcp/cloud/server_test.go @@ -0,0 +1,37 @@ +package cloud + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) +} + +// 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..75e699bf --- /dev/null +++ b/pkg/mcp/cloud/specs.go @@ -0,0 +1,36 @@ +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: "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..e0149b3e --- /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 [\"compute\",\"firewall-rules\",\"list\",\"--project\",\"prod\"]). Do not include the binary name or pass a single string."` +} + +// 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..7d3cdfc4 --- /dev/null +++ b/pkg/mcp/cloud/tools_inventory.go @@ -0,0 +1,28 @@ +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. 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. The provider execs +// only through the server's validated run core. +func (s *Server) listInventory(ctx context.Context, _ *mcp.CallToolRequest, _ ListInventoryInput) (*mcp.CallToolResult, ListInventoryOutput, error) { + inv, err := s.provider.Inventory(ctx, s.run) + 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..08ead5e0 --- /dev/null +++ b/pkg/mcp/cloud/tools_status.go @@ -0,0 +1,28 @@ +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) + if err != nil { + return errorResult(err.Error()), SessionStatusOutput{}, nil + } + 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..9a705711 --- /dev/null +++ b/pkg/mcp/cloud/tools_test.go @@ -0,0 +1,113 @@ +package cloud + +import ( + "context" + "testing" + + "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 +} + +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 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: "projects list"}}}, + } + srv := newTestServer(t, p, func(o *Options) { + o.Scope = ScopeAllowlist{Projects: []string{"prod"}} + }) + res, _, err := srv.runCLI(context.Background(), nil, RunCLIInput{ + Argv: []string{"projects", "list", "--project", "other"}, + }) + 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..9cf246c5 --- /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 four 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", "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..770b995d --- /dev/null +++ b/pkg/mcp/cloud/validate.go @@ -0,0 +1,125 @@ +package cloud + +import ( + "fmt" + "strings" +) + +// ScopeAllowlist is the deployment's set of cloud targets any run_cli argv may +// reference. An empty field means that target axis is unconstrained. The agent +// cannot pivot to an un-allowlisted project, account, or region. +type ScopeAllowlist struct { + Projects []string `json:"projects,omitempty"` + 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. The deny floor rejects identity +// flags (--account, --profile) before scope ever sees them, so scope governs +// only the location axes the agent is allowed to choose among. +func (s ScopeAllowlist) allowedFor(flag string) ([]string, bool) { + switch flag { + case "--project": + return s.Projects, true + case "--region", "--zone": + return s.Regions, true + default: + return nil, false + } +} + +// validateArgv enforces the no-bypass contract on one argv before exec: 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 metacharacter tokens +// are inert positionals that simply fail the exact allowlist match. +func validateArgv(argv []string, allow *CommandAllowlist, scope ScopeAllowlist) error { + if len(argv) == 0 { + return fmt.Errorf("empty command") + } + 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 +} + +// 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..ba77a32e --- /dev/null +++ b/pkg/mcp/cloud/validate_test.go @@ -0,0 +1,68 @@ +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{Projects: []string{"prod"}, Regions: []string{"us-central1"}} + cases := []struct { + name string + argv []string + ok bool + }{ + {"allowed", []string{"compute", "instances", "list", "--project", "prod"}, true}, + {"allowed-region", []string{"compute", "instances", "list", "--project", "prod", "--region", "us-central1"}, true}, + {"bad-scope", []string{"compute", "instances", "list", "--project", "other"}, false}, + {"bad-region", []string{"compute", "instances", "list", "--project", "prod", "--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}, + {"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}, + {"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{Projects: []string{"prod"}} + // --project=other 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", "--project=other"}, al, scope), + "expected --project=other (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.NoError(t, validateArgv([]string{"compute", "instances", "list", "--project=prod"}, al, scope), + "expected --project=prod (equals form, in scope) to validate") +} + +func TestValidateArgvEmptyScopeAllowsAnyTarget(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "projects list"}}} + // An empty scope means the deployment did not constrain targets; the scope + // check must not reject a --project then. + assert.NoError(t, validateArgv([]string{"projects", "list", "--project", "anything"}, al, ScopeAllowlist{}), + "empty scope should not reject a target") +} From 98f7d4010f72798667ba4b762cebf500ffe8d501 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 05:04:37 +0200 Subject: [PATCH 06/76] chore(state): #45 self-merged (#48), contracts locked, Phase 2 unblocked Co-Authored-By: Claude Opus 4.8 (1M context) --- .../states/2026-05-30-cloud-context-mcp-state.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index e9577866..49dc9ee9 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -21,7 +21,7 @@ status: developing | Issue | Branch | Worktree path | PR (→ base) | Status | | ----- | ------ | ------------- | ----------- | ------ | -| #45 — scaffold + harness | feature/cloud-context-mcp--scaffold | .claude/worktrees/cloud-context-mcp--scaffold | _tbd_ → feature/cloud-context-mcp | dispatched | +| #45 — scaffold + harness | (merged, branch deleted) | (removed) | #48 → feature/cloud-context-mcp | self-merged | | #43 — GCP provider | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | | #46 — AWS provider | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | | #47 — launcher integration | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | @@ -30,12 +30,12 @@ status: developing | Name | Realization | Realized in | Status | | ---- | ----------- | ----------- | ------ | -| `cloud-provider-interface` | stub-on-producer-branch (`cloud.Provider` + `fakeProvider` land in #45) | #45 | pending | -| `cloud-identity-probe` | stub-on-producer-branch (`cloud.Probe` + `IdentityStatus` exported by #45) | #45 | pending | -| `cloud-serve-cli` | data-only (`serve --kind=cloud --provider=`) | n/a | pending | -| `cloud-env-contract` | data-only (`TRIAGENT_CLOUD_*` + impersonation env consts) | #45 / #43 / #46 | pending | +| `cloud-provider-interface` | stub-on-producer-branch (`cloud.Provider` + `fakeProvider` land in #45) | #45 (#48) | locked | +| `cloud-identity-probe` | stub-on-producer-branch (`cloud.Probe` + `IdentityStatus` exported by #45) | #45 (#48) | locked | +| `cloud-serve-cli` | data-only (`serve --kind=cloud --provider=`) | #45 (#48) | locked | +| `cloud-env-contract` | data-only (`TRIAGENT_CLOUD_*` consts in `cloud/env.go`; provider impersonation env via `Provider.EnvPassthrough() []string`) | #45 (#48), provider names in #43/#46 | locked | -All four contracts are produced by #45, so Phase 2 cannot start until #45 merges into the feature branch. They flip to `locked` once #45's interface, probe, and env constants land. +All four contracts landed with #45 (squash-merged as #48). Phase 2 (#43/#46/#47) is now unblocked. The `Provider` interface gained `EnvPassthrough() []string` during #45 review (see Bubble-up log) — #43/#46 must implement it, returning their CLI's credential/impersonation var names; `PATH`/`HOME` are already in the harness base set. ## Bubble-up log From 189362d0675c3e56843c087e14ceb4d6706d8644 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 05:11:45 +0200 Subject: [PATCH 07/76] chore(state): dispatch Wave 2 (#43/#46/#47); log serve.go resource conflict Co-Authored-By: Claude Opus 4.8 (1M context) --- .../states/2026-05-30-cloud-context-mcp-state.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index 49dc9ee9..b2a0bb48 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -22,9 +22,9 @@ status: developing | Issue | Branch | Worktree path | PR (→ base) | Status | | ----- | ------ | ------------- | ----------- | ------ | | #45 — scaffold + harness | (merged, branch deleted) | (removed) | #48 → feature/cloud-context-mcp | self-merged | -| #43 — GCP provider | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | -| #46 — AWS provider | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | -| #47 — launcher integration | _tbd_ | _tbd_ | _tbd_ → feature/cloud-context-mcp | not-started | +| #43 — GCP provider | feature/cloud-context-mcp--gcp | .claude/worktrees/cloud-context-mcp--gcp | _tbd_ → feature/cloud-context-mcp | dispatched | +| #46 — AWS provider | feature/cloud-context-mcp--aws | .claude/worktrees/cloud-context-mcp--aws | _tbd_ → feature/cloud-context-mcp | dispatched | +| #47 — launcher integration | feature/cloud-context-mcp--launcher | .claude/worktrees/cloud-context-mcp--launcher | _tbd_ → feature/cloud-context-mcp | dispatched | ## Contracts @@ -39,6 +39,8 @@ All four contracts landed with #45 (squash-merged as #48). Phase 2 (#43/#46/#47) ## Bubble-up log +- **2026-05-30 — known `serve.go` resource conflict between #43 and #46 (dispatch-time, pre-logged).** Both providers wire into `cmd/triagent-mcp/serve.go`: each adds an import (`providers/gcp` vs `providers/aws`) to the same import group and replaces its arm of the `newCloudProvider` stub switch (currently a combined `case "gcp", "aws":`). The import-group collision makes a trivial conflict inevitable at whichever provider PR merges **second**. **Resolution (orchestrator owns it):** dispatch both in parallel; each agent makes a minimal, localized edit (only its own import + its own case arm, leaving the other arm's "not built yet" stub untouched). At the second provider merge, resolve by taking the union — both imports, both real case arms. #47 (launcher) touches a disjoint file set and is conflict-free. + - **2026-05-30 — minimal-env seam missing in the harness (blocks #45 merge).** `cloud.Server.run` (server.go) calls `execCLI(..., argv, nil, ...)`; in Go a nil `cmd.Env` inherits the full parent environment, contradicting the spec's "explicit minimal `cmd.Env`" and `harness.go`'s own doc comment, and leaking the launcher's process env into `gcloud`/`aws`. The env-forwarding seam is owned by the parent package (conventions: subpackages own only CLI specifics), so it must land in #45 before fan-out. Resolution: #45 follow-up adds a provider-contributed env-passthrough (var **names** the CLI needs forwarded) merged with a minimal base set, built once and passed to `execCLI`; `fakeProvider` returns none. **Propagation:** #43/#46 implement the new `Provider` env-passthrough method; #47 unaffected (still injects env onto the `triagent-mcp` process). Interface grows by one method before consumers branch. - **2026-05-30 — tests must use `testify` (user directive).** All cloud tests convert to `assert`/`require`; CLAUDE.md amended to make this the repo standard (testify is already used in 166 test files). **Propagation:** #43/#46/#47 inherit the rule via CLAUDE.md; their tests use testify from the start. From 7a36b2ad61e955cef10fff9827e5edab04e8ee50 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 05:19:51 +0200 Subject: [PATCH 08/76] chore(state): re-sequence #47 to Wave 2b (depends on #43+#46); add provider-factory contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #47's preflight/connections probe constructs cloud.Provider values to call cloud.Probe, so it imports the provider packages — it cannot compile until both #43 and #46 land. Correct the plan's parallel claim and add a shared provider factory (pkg/mcp/cloud/providers) as #47's first task. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-30-cloud-context-mcp.md | 20 +++++++++++++++---- .../2026-05-30-cloud-context-mcp-state.md | 10 +++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md b/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md index 4bed9192..7e0252cf 100644 --- a/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md +++ b/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md @@ -21,9 +21,9 @@ The feature lands via the feature-branch model on `feature/cloud-context-mcp`. F | **A — scaffold + harness** | #45 | `pkg/mcp/cloud/`: `Provider` interface, command allowlist + deny floor, `run_cli` harness, `list_allowed_commands`, typed `list_inventory` + `session_status` against a fake provider, the shared identity probe, `serve.go` `--kind=cloud --provider=` wiring, wire test. | — | | **B — GCP provider** | #43 | `pkg/mcp/cloud/providers/gcp`: implements `Provider` over `gcloud`; default allowlist + deny-floor additions; impersonation env contract. | A (interface) | | **C — AWS provider** | #46 | `pkg/mcp/cloud/providers/aws`: implements `Provider` over `aws`; default allowlist + deny-floor additions; assume-role profile contract. | A (interface) | -| **D — launcher integration** | NEW | profile `cloud:` block; `mcpconfig.go` aliasing + env injection; `preflight` cloud probe + visible degrade; `connections` cloud array + `GET /api/connections`; frontend read-only pill. | A (probe), B/C (env contracts) | +| **D — launcher integration** | #47 | shared provider factory `pkg/mcp/cloud/providers`; profile `cloud:` block; `mcpconfig.go` aliasing + env injection; `preflight` cloud probe + visible degrade; `connections` cloud array + `GET /api/connections`; frontend read-only pill. | A (probe), **B + C (provider construction)** | -B, C, and D run in parallel once A's contracts are realized. The plan is written so each PR is independently reviewable and leaves `make test` green. +B and C run in parallel once A's contracts are realized. D runs **after both B and C merge**: its preflight + connections probe constructs `cloud.Provider` values to call `cloud.Probe`, so it imports the provider packages via a shared factory and cannot compile until both land. Each PR is independently reviewable and leaves `make test` green. ## File structure @@ -342,9 +342,21 @@ Mirror of PR B over the `aws` CLI. Branches from A's merged state; independent o - [ ] `--provider=aws` constructs `aws.New()`; `go test ./... -race` + `make lint` → PASS. - [ ] Commit `feat(cloud): wire aws provider into serve.go (#46)`. -## PR D — launcher integration (NEW issue) +## PR D — launcher integration (#47) -Branches from A's merged state (needs `cloud.Probe`, `IdentityStatus`, env-const names). Independent of B/C at compile time (references env-var name constants, not provider packages). +Branches from the feature branch **after both B and C have merged** (needs `cloud.Probe`, `IdentityStatus`, the env-const names, and a constructed `cloud.Provider` per source). It depends on the provider packages at compile time: D3/D4 call `cloud.Probe(ctx, cloud.Provider)`, and the only way to obtain a `cloud.Provider` is to construct a concrete gcp/aws value. D therefore introduces a shared factory `pkg/mcp/cloud/providers.New(name) (cloud.Provider, error)` (importing gcp + aws), refactors `cmd/triagent-mcp/serve.go`'s `newCloudProvider` to delegate to it, and uses it in `preflight` and `connections` — mirroring how the launcher already builds `auth.Provider` from `pkg/auth/teleport` / `pkg/auth/kubeconfig`. + +### Task D0: Shared provider factory + +**Files:** +- Create: `pkg/mcp/cloud/providers/registry.go` +- Modify: `cmd/triagent-mcp/serve.go` (delegate `newCloudProvider` to the factory) +- Test: `pkg/mcp/cloud/providers/registry_test.go` + +- [ ] **Step 1:** Failing test — `New("gcp")` returns a non-nil `cloud.Provider` whose `Name()` is `"gcp"`; `New("aws")` likewise; an unknown name errors. +- [ ] **Step 2:** Implement `New(name)` switching to `gcp.New()` / `aws.New()`; refactor `serve.go`'s `newCloudProvider` to call it (removing the per-arm construction the providers added — the factory is now the single construction site). +- [ ] **Step 3:** Run `go test ./pkg/mcp/cloud/providers/ ./cmd/triagent-mcp/ -race` → PASS. +- [ ] **Step 4:** Commit `feat(cloud): shared provider factory; serve.go delegates construction (#47)`. ### Task D1: Profile `cloud:` block diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index b2a0bb48..0d8d63af 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -14,8 +14,9 @@ status: developing ## Phases -- **Phase 1 (foundational)** — `#45` (scaffold + harness; produces every contract) -- **Phase 2 (consumers, parallel)** — `#43` (GCP provider), `#46` (AWS provider), `#47` (launcher integration) +- **Phase 1 (foundational)** — `#45` (scaffold + harness; produces every contract). **Done** — self-merged as #48. +- **Phase 2a (providers, parallel)** — `#43` (GCP provider), `#46` (AWS provider). In flight. +- **Phase 2b (launcher, gated)** — `#47` (launcher integration). Gated on **both** #43 and #46 self-merging: its preflight + connections probe constructs `cloud.Provider` values to call `cloud.Probe`, so it imports the provider packages (see Bubble-up log). Dispatched only after 2a merges and the shared provider factory exists. ## PRs / worktrees @@ -24,7 +25,7 @@ status: developing | #45 — scaffold + harness | (merged, branch deleted) | (removed) | #48 → feature/cloud-context-mcp | self-merged | | #43 — GCP provider | feature/cloud-context-mcp--gcp | .claude/worktrees/cloud-context-mcp--gcp | _tbd_ → feature/cloud-context-mcp | dispatched | | #46 — AWS provider | feature/cloud-context-mcp--aws | .claude/worktrees/cloud-context-mcp--aws | _tbd_ → feature/cloud-context-mcp | dispatched | -| #47 — launcher integration | feature/cloud-context-mcp--launcher | .claude/worktrees/cloud-context-mcp--launcher | _tbd_ → feature/cloud-context-mcp | dispatched | +| #47 — launcher integration | _tbd (Wave 2b)_ | _tbd (Wave 2b)_ | _tbd_ → feature/cloud-context-mcp | blocked (Wave 2b: needs #43 + #46 merged) | ## Contracts @@ -34,11 +35,14 @@ status: developing | `cloud-identity-probe` | stub-on-producer-branch (`cloud.Probe` + `IdentityStatus` exported by #45) | #45 (#48) | locked | | `cloud-serve-cli` | data-only (`serve --kind=cloud --provider=`) | #45 (#48) | locked | | `cloud-env-contract` | data-only (`TRIAGENT_CLOUD_*` consts in `cloud/env.go`; provider impersonation env via `Provider.EnvPassthrough() []string`) | #45 (#48), provider names in #43/#46 | locked | +| `cloud-provider-factory` | new (discovered): `pkg/mcp/cloud/providers.New(name) (cloud.Provider, error)`, importing gcp+aws; `serve.go` + `preflight` + `connections` consume it | #47 (Wave 2b) | pending | All four contracts landed with #45 (squash-merged as #48). Phase 2 (#43/#46/#47) is now unblocked. The `Provider` interface gained `EnvPassthrough() []string` during #45 review (see Bubble-up log) — #43/#46 must implement it, returning their CLI's credential/impersonation var names; `PATH`/`HOME` are already in the harness base set. ## Bubble-up log +- **2026-05-30 — discovered cross-PR dependency: #47 depends on #43 + #46 at compile time (plan corrected).** The plan claimed PR D (launcher) is "independent of B/C at compile time (references env-var name constants, not provider packages)." That is wrong: D3 (preflight) and D4 (connections) call `cloud.Probe(ctx, cloud.Provider)`, which needs a concrete `cloud.Provider`. A factory can't live in the `cloud` package (gcp/aws import `cloud`, so it would cycle); it must be a neutral package importing both providers — mirroring how the launcher already imports `pkg/auth/teleport` + `pkg/auth/kubeconfig` to build `auth.Provider`. **Resolution:** re-sequenced #47 to Phase 2b (after #43 + #46 self-merge). #47 introduces a shared provider factory `pkg/mcp/cloud/providers` (`New(name) (cloud.Provider, error)`) and refactors `cmd/triagent-mcp/serve.go`'s `newCloudProvider` to delegate to it — a third consumer (serve.go, preflight, connections) justifies the shared helper over copy-paste. **Propagation:** the premature #47 worktree/branch was removed; the plan's PR-breakdown dependency column and PR-D header are corrected; a `cloud-provider-factory` contract row is added. #43/#46 are unaffected (each still wires only its own `serve.go` arm; the factory extraction happens in #47 once serve.go is no longer contended). + - **2026-05-30 — known `serve.go` resource conflict between #43 and #46 (dispatch-time, pre-logged).** Both providers wire into `cmd/triagent-mcp/serve.go`: each adds an import (`providers/gcp` vs `providers/aws`) to the same import group and replaces its arm of the `newCloudProvider` stub switch (currently a combined `case "gcp", "aws":`). The import-group collision makes a trivial conflict inevitable at whichever provider PR merges **second**. **Resolution (orchestrator owns it):** dispatch both in parallel; each agent makes a minimal, localized edit (only its own import + its own case arm, leaving the other arm's "not built yet" stub untouched). At the second provider merge, resolve by taking the union — both imports, both real case arms. #47 (launcher) touches a disjoint file set and is conflict-free. - **2026-05-30 — minimal-env seam missing in the harness (blocks #45 merge).** `cloud.Server.run` (server.go) calls `execCLI(..., argv, nil, ...)`; in Go a nil `cmd.Env` inherits the full parent environment, contradicting the spec's "explicit minimal `cmd.Env`" and `harness.go`'s own doc comment, and leaking the launcher's process env into `gcloud`/`aws`. The env-forwarding seam is owned by the parent package (conventions: subpackages own only CLI specifics), so it must land in #45 before fan-out. Resolution: #45 follow-up adds a provider-contributed env-passthrough (var **names** the CLI needs forwarded) merged with a minimal base set, built once and passed to `execCLI`; `fakeProvider` returns none. **Propagation:** #43/#46 implement the new `Provider` env-passthrough method; #47 unaffected (still injects env onto the `triagent-mcp` process). Interface grows by one method before consumers branch. From 3b7dcc276ef487ff476480a15fde8a36d8211f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 05:28:01 +0200 Subject: [PATCH 09/76] feat(cloud/gcp): GCP provider for the cloud-context MCP (#49) * feat(cloud/gcp): provider skeleton, default allowlist, deny-floor additions (#43) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud/gcp): identity probe over impersonation (#43) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud/gcp): inventory projection (#43) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): wire gcp provider into serve.go (#43) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 12 +- .../cloud/providers/gcp/default_commands.json | 36 ++++++ pkg/mcp/cloud/providers/gcp/identity.go | 70 ++++++++++++ pkg/mcp/cloud/providers/gcp/identity_test.go | 107 ++++++++++++++++++ pkg/mcp/cloud/providers/gcp/inventory.go | 39 +++++++ pkg/mcp/cloud/providers/gcp/inventory_test.go | 77 +++++++++++++ pkg/mcp/cloud/providers/gcp/provider.go | 89 +++++++++++++++ pkg/mcp/cloud/providers/gcp/provider_test.go | 68 +++++++++++ 8 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 pkg/mcp/cloud/providers/gcp/default_commands.json create mode 100644 pkg/mcp/cloud/providers/gcp/identity.go create mode 100644 pkg/mcp/cloud/providers/gcp/identity_test.go create mode 100644 pkg/mcp/cloud/providers/gcp/inventory.go create mode 100644 pkg/mcp/cloud/providers/gcp/inventory_test.go create mode 100644 pkg/mcp/cloud/providers/gcp/provider.go create mode 100644 pkg/mcp/cloud/providers/gcp/provider_test.go diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index 1b81ecb8..bed832fb 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -12,6 +12,7 @@ import ( "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/gcp" "github.com/sourcehawk/triagent/pkg/mcp/git" "github.com/sourcehawk/triagent/pkg/mcp/incidentio" "github.com/sourcehawk/triagent/pkg/mcp/k8s" @@ -460,13 +461,14 @@ func runCloud(ctx context.Context, f serveFlags) error { return srv.Run(ctx) } -// newCloudProvider constructs the cloud.Provider for the named provider. The -// gcp and aws implementations land in pkg/mcp/cloud/providers/ in their -// own PRs; until then a known provider reports that it is not yet built and an -// unknown one is named in the error. +// newCloudProvider constructs the cloud.Provider for the named provider. Each +// implementation lives in pkg/mcp/cloud/providers/; an unknown provider is +// named in the error. func newCloudProvider(name string) (cloud.Provider, error) { switch name { - case "gcp", "aws": + case "gcp": + return gcp.New() + case "aws": return nil, fmt.Errorf("cloud provider %q is not built yet", name) default: return nil, fmt.Errorf("unknown cloud --provider %q (want gcp or aws)", name) 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..b39d2980 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/identity.go @@ -0,0 +1,70 @@ +package gcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +// authAccount is one entry of `gcloud auth list --format=json`. +type authAccount struct { + Account string `json:"account"` + Status string `json:"status"` +} + +// 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: it reads the active account and reports the session valid only when +// that account equals the pinned impersonation target the launcher set in +// CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT. A degraded auth state surfaces +// through Valid and Hint, never a Go error. +func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (cloud.IdentityStatus, error) { + target := os.Getenv(impersonationEnv) + + res, err := run(ctx, []string{"auth", "list", "--filter=status:ACTIVE", "--format=json"}) + if err != nil { + return cloud.IdentityStatus{Provider: "gcp", Valid: false, Hint: err.Error()}, nil + } + + var accounts []authAccount + if err := json.Unmarshal([]byte(res.Stdout), &accounts); err != nil { + return cloud.IdentityStatus{ + Provider: "gcp", + Valid: false, + Hint: fmt.Sprintf("parse gcloud auth list output: %v", err), + }, nil + } + + active := activeAccount(accounts) + st := cloud.IdentityStatus{Provider: "gcp", AssumedIdentity: active} + + switch { + case target == "": + st.Valid = false + st.Hint = "no impersonation target pinned; set " + impersonationEnv + " on the cloud MCP subprocess" + case active == "": + st.Valid = false + st.Hint = "no active gcloud account; run: gcloud auth login" + case active != target: + st.Valid = false + st.Hint = fmt.Sprintf("active account %q is not the pinned identity %q", active, target) + default: + st.Valid = true + } + return st, nil +} + +// activeAccount returns the first account marked ACTIVE, or "" when none is. The +// --filter=status:ACTIVE argv already narrows this server-side; the status check +// is the belt to that braces. +func activeAccount(accounts []authAccount) string { + for _, a := range accounts { + if a.Status == "ACTIVE" { + return a.Account + } + } + return "" +} 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..f7378923 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/identity_test.go @@ -0,0 +1,107 @@ +package gcp + +import ( + "context" + "errors" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authListJSON is captured `gcloud auth list --format=json` output: an array of +// accounts, exactly one with status ACTIVE. +const authListJSON = `[ + { + "account": "ro-sa@proj.iam.gserviceaccount.com", + "status": "ACTIVE" + }, + { + "account": "operator@example.com", + "status": "" + } +]` + +func runReturning(out string) cloud.RunFunc { + return func(context.Context, []string) (cloud.CLIResult, error) { + return cloud.CLIResult{Stdout: out}, nil + } +} + +func TestIdentityResolvesActiveAccountAsTarget(t *testing.T) { + t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + st, err := p.Identity(context.Background(), runReturning(authListJSON)) + require.NoError(t, err) + assert.Equal(t, "gcp", st.Provider) + assert.Equal(t, "ro-sa@proj.iam.gserviceaccount.com", st.AssumedIdentity) + assert.True(t, st.Valid, "active account equals the impersonation target") +} + +func TestIdentityInvalidWhenActiveAccountIsNotTheTarget(t *testing.T) { + t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + mismatch := `[{"account": "operator@example.com", "status": "ACTIVE"}]` + st, err := p.Identity(context.Background(), runReturning(mismatch)) + require.NoError(t, err) + assert.Equal(t, "operator@example.com", st.AssumedIdentity) + assert.False(t, st.Valid, "active account differs from the impersonation target") + assert.NotEmpty(t, st.Hint) +} + +func TestIdentityInvalidWhenNoActiveAccount(t *testing.T) { + t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + st, err := p.Identity(context.Background(), runReturning(`[]`)) + require.NoError(t, err) + assert.Empty(t, st.AssumedIdentity) + assert.False(t, st.Valid) + assert.NotEmpty(t, st.Hint) +} + +func TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { + t.Setenv(impersonationEnv, "") + p, err := newWithBinary("/usr/bin/gcloud") + require.NoError(t, err) + + st, err := p.Identity(context.Background(), runReturning(authListJSON)) + require.NoError(t, err) + assert.False(t, st.Valid, "no pinned target means the session is not validly pinned") + assert.NotEmpty(t, st.Hint) +} + +func TestIdentitySurfacesRunErrorAsHint(t *testing.T) { + t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + 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("gcloud not authenticated") + }) + st, err := p.Identity(context.Background(), failing) + 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") +} + +func TestIdentityCallsAuthListWithJSONFormat(t *testing.T) { + t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + 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: authListJSON}, nil + }) + _, err = p.Identity(context.Background(), capturing) + require.NoError(t, err) + assert.Equal(t, []string{"auth", "list", "--filter=status:ACTIVE", "--format=json"}, gotArgv) +} diff --git a/pkg/mcp/cloud/providers/gcp/inventory.go b/pkg/mcp/cloud/providers/gcp/inventory.go new file mode 100644 index 00000000..bdf1cba9 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/inventory.go @@ -0,0 +1,39 @@ +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 pinned identity can read, projected to id + +// name. It is called with the server's validated RunFunc, so the argv must match +// the allowlisted `projects list` verb chain exactly. A run error here is a real +// failure of the inventory tool and is returned to the caller, unlike the +// identity probe which degrades. +func (p *Provider) Inventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { + res, err := run(ctx, []string{"projects", "list", "--format=json"}) + if err != nil { + return cloud.Inventory{}, fmt.Errorf("gcloud projects list: %w", err) + } + + 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..66404f48 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/inventory_test.go @@ -0,0 +1,77 @@ +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 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 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..245d2d32 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -0,0 +1,89 @@ +// 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" + "fmt" + "os/exec" + + "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 + +// impersonationEnv 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 impersonationEnv = "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT" + +var _ cloud.Provider = (*Provider)(nil) + +// Provider implements cloud.Provider over the gcloud CLI. +type Provider struct { + binary string + allowlist *cloud.CommandAllowlist +} + +// 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. +func New() (*Provider, error) { + bin, err := exec.LookPath("gcloud") + if err != nil { + return nil, fmt.Errorf("gcp: resolve gcloud binary: %w", err) + } + return newWithBinary(bin) +} + +// newWithBinary builds the provider against an already-resolved binary path. It +// is the seam tests inject a fixed path through, bypassing exec.LookPath. +func newWithBinary(binary string) (*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) + } + return &Provider{binary: binary, allowlist: &list}, 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, or mutate by side effect, on top of the base floor. +func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { + return cloud.DenyFloor{ + Subcommands: []string{ + "compute ssh", + "compute scp", + "compute reset-windows-password", + "functions call", + }, + } +} + +// 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{ + impersonationEnv, + "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..18c17026 --- /dev/null +++ b/pkg/mcp/cloud/providers/gcp/provider_test.go @@ -0,0 +1,68 @@ +package gcp + +import ( + "testing" + + "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()) +} + +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) + } +} From cffa1920c8933985267dba8161fd5c4dbe61246c 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 05:29:22 +0200 Subject: [PATCH 10/76] chore(state): #43 self-merged (#49); log probe-env, expected-identity-env, and #46 binary findings Co-Authored-By: Claude Opus 4.8 (1M context) --- .../states/2026-05-30-cloud-context-mcp-state.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index 0d8d63af..b27ac8cc 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -23,7 +23,7 @@ status: developing | Issue | Branch | Worktree path | PR (→ base) | Status | | ----- | ------ | ------------- | ----------- | ------ | | #45 — scaffold + harness | (merged, branch deleted) | (removed) | #48 → feature/cloud-context-mcp | self-merged | -| #43 — GCP provider | feature/cloud-context-mcp--gcp | .claude/worktrees/cloud-context-mcp--gcp | _tbd_ → feature/cloud-context-mcp | dispatched | +| #43 — GCP provider | (merged, branch deleted) | (removed) | #49 → feature/cloud-context-mcp | self-merged | | #46 — AWS provider | feature/cloud-context-mcp--aws | .claude/worktrees/cloud-context-mcp--aws | _tbd_ → feature/cloud-context-mcp | dispatched | | #47 — launcher integration | _tbd (Wave 2b)_ | _tbd (Wave 2b)_ | _tbd_ → feature/cloud-context-mcp | blocked (Wave 2b: needs #43 + #46 merged) | @@ -41,6 +41,10 @@ All four contracts landed with #45 (squash-merged as #48). Phase 2 (#43/#46/#47) ## Bubble-up log +- **2026-05-30 — `probe.go` exec path still inherits the full parent env (parent-package follow-up needed).** `cloud.Probe` builds a `RunFunc` that calls `execCLI(ctx, p.Binary(), argv, nil, …)`. A `nil` `cmd.Env` inherits the **entire** parent environment — the same minimal-env spec violation #45's review fixed in `Server.run` (now uses `subprocessEnv()`), but the probe path was missed. So the identity probe (used by `session_status`, preflight, connections) leaks the launcher's ambient env into the `gcloud`/`aws` subprocess, while `run_cli`/`Inventory` do not — an inconsistency. The probe argv is provider-fixed (agent can't inject), so exfil risk is low, but it's the same spec breach and an asymmetry. **Resolution:** a small parent-package follow-up extracts the minimal-env helper shared by `Server.subprocessEnv` and `Probe`, so the probe forwards only base (PATH/HOME) + `p.EnvPassthrough()`. Surfaced by #46; do it after #43/#46 merge (touches only `probe.go`/`server.go`, no provider conflict). **Note:** providers read their *expected-identity* env via `os.Getenv` in their own process (not the subprocess), so this fix does not change identity-validity logic — only what the whoami subprocess sees. +- **2026-05-30 — per-provider "expected pinned identity" env diverges; #47 must reconcile (coherence).** GCP derives identity validity from `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT` (the impersonation target doubles as the expected identity). AWS added a separate optional `TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN` (strict) and otherwise checks structurally that the caller is an assumed-role. Also, the plan's D2 maps AWS's impersonation env to `AWS_PROFILE` (a profile NAME), but `AssumedIdentity` in the profile is a role ARN — AWS needs BOTH a profile selector (`AWS_PROFILE`) and the expected role ARN, which the current `CloudSource{Alias, Provider, AssumedIdentity, Scope, CommandAllowlistPath}` model doesn't cleanly express. **Resolution (owned by #47 dispatch):** #47 reconciles per-provider env injection in `mcpconfig.go` — gcp: `AssumedIdentity → CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT`; aws: a profile field/Alias → `AWS_PROFILE` plus `AssumedIdentity (role ARN) → TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN`. Decide whether `CloudSource` needs an explicit AWS profile field. Flagged into #47's dispatch context. +- **2026-05-30 — #46 binary fallback (security + coherence, fixing pre-merge).** AWS `New()` fell back to the relative literal `"aws"` when the CLI wasn't on PATH, defeating the spec's absolute-binary pin (poisoned-PATH substitution) and diverging from GCP (which errors + uses a `newWithBinary` test seam). **Resolution:** focused #46 follow-up aligns AWS with GCP (error on missing binary; tests inject via `newWithBinary`). The missing-binary degrade belongs at the launcher (#47 marks the source unavailable), not a relative-path fallback. + - **2026-05-30 — discovered cross-PR dependency: #47 depends on #43 + #46 at compile time (plan corrected).** The plan claimed PR D (launcher) is "independent of B/C at compile time (references env-var name constants, not provider packages)." That is wrong: D3 (preflight) and D4 (connections) call `cloud.Probe(ctx, cloud.Provider)`, which needs a concrete `cloud.Provider`. A factory can't live in the `cloud` package (gcp/aws import `cloud`, so it would cycle); it must be a neutral package importing both providers — mirroring how the launcher already imports `pkg/auth/teleport` + `pkg/auth/kubeconfig` to build `auth.Provider`. **Resolution:** re-sequenced #47 to Phase 2b (after #43 + #46 self-merge). #47 introduces a shared provider factory `pkg/mcp/cloud/providers` (`New(name) (cloud.Provider, error)`) and refactors `cmd/triagent-mcp/serve.go`'s `newCloudProvider` to delegate to it — a third consumer (serve.go, preflight, connections) justifies the shared helper over copy-paste. **Propagation:** the premature #47 worktree/branch was removed; the plan's PR-breakdown dependency column and PR-D header are corrected; a `cloud-provider-factory` contract row is added. #43/#46 are unaffected (each still wires only its own `serve.go` arm; the factory extraction happens in #47 once serve.go is no longer contended). - **2026-05-30 — known `serve.go` resource conflict between #43 and #46 (dispatch-time, pre-logged).** Both providers wire into `cmd/triagent-mcp/serve.go`: each adds an import (`providers/gcp` vs `providers/aws`) to the same import group and replaces its arm of the `newCloudProvider` stub switch (currently a combined `case "gcp", "aws":`). The import-group collision makes a trivial conflict inevitable at whichever provider PR merges **second**. **Resolution (orchestrator owns it):** dispatch both in parallel; each agent makes a minimal, localized edit (only its own import + its own case arm, leaving the other arm's "not built yet" stub untouched). At the second provider merge, resolve by taking the union — both imports, both real case arms. #47 (launcher) touches a disjoint file set and is conflict-free. From 09c094622e1d974daa93ec72dc35c1ca9fcd7ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 05:38:56 +0200 Subject: [PATCH 11/76] feat(cloud/aws): AWS provider for the cloud-context MCP (#50) * feat(cloud/aws): provider skeleton, default allowlist, deny-floor additions (#46) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud/aws): identity probe over assumed role (#46) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud/aws): inventory projection with single-account fallback (#46) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): wire aws provider into serve.go (#46) Co-Authored-By: Claude Opus 4.8 (1M context) * chore(cloud/aws): lowercase fixture error string for staticcheck (#46) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud/aws): error on missing aws binary instead of falling back to a relative path (#46) New() now resolves aws to an absolute path via exec.LookPath and errors when it is absent, matching the gcp provider. The relative "aws" fallback defeated the startup-resolution guarantee: a poisoned PATH could substitute a different binary at exec time. A missing-binary deployment is handled by the launcher (#47) marking the cloud source unavailable, not by a fallback inside the provider. Adds the newWithBinary seam (mirroring gcp) so tests inject a fixed path and stay hermetic on a CI box without the aws CLI installed. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 3 +- cmd/triagent-mcp/serve_cloud_test.go | 8 ++ .../cloud/providers/aws/default_commands.json | 46 +++++++ pkg/mcp/cloud/providers/aws/identity.go | 101 +++++++++++++++ pkg/mcp/cloud/providers/aws/identity_test.go | 106 ++++++++++++++++ pkg/mcp/cloud/providers/aws/inventory.go | 68 ++++++++++ pkg/mcp/cloud/providers/aws/inventory_test.go | 71 +++++++++++ pkg/mcp/cloud/providers/aws/provider.go | 104 ++++++++++++++++ pkg/mcp/cloud/providers/aws/provider_test.go | 117 ++++++++++++++++++ 9 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 pkg/mcp/cloud/providers/aws/default_commands.json create mode 100644 pkg/mcp/cloud/providers/aws/identity.go create mode 100644 pkg/mcp/cloud/providers/aws/identity_test.go create mode 100644 pkg/mcp/cloud/providers/aws/inventory.go create mode 100644 pkg/mcp/cloud/providers/aws/inventory_test.go create mode 100644 pkg/mcp/cloud/providers/aws/provider.go create mode 100644 pkg/mcp/cloud/providers/aws/provider_test.go diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index bed832fb..112e6854 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -12,6 +12,7 @@ import ( "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/aws" "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" "github.com/sourcehawk/triagent/pkg/mcp/git" "github.com/sourcehawk/triagent/pkg/mcp/incidentio" @@ -469,7 +470,7 @@ func newCloudProvider(name string) (cloud.Provider, error) { case "gcp": return gcp.New() case "aws": - return nil, fmt.Errorf("cloud provider %q is not built yet", name) + return aws.New() default: return nil, fmt.Errorf("unknown cloud --provider %q (want gcp or aws)", name) } diff --git a/cmd/triagent-mcp/serve_cloud_test.go b/cmd/triagent-mcp/serve_cloud_test.go index 32504a07..e398968d 100644 --- a/cmd/triagent-mcp/serve_cloud_test.go +++ b/cmd/triagent-mcp/serve_cloud_test.go @@ -34,3 +34,11 @@ func TestServeCmd_KnowsCloudKind(t *testing.T) { cmd := serveCmd() assert.Contains(t, cmd.Long, "cloud", "serve --help should list cloud") } + +func TestNewCloudProvider_AWSIsBuilt(t *testing.T) { + t.Parallel() + p, err := newCloudProvider("aws") + require.NoError(t, err) + require.NotNil(t, p) + assert.Equal(t, "aws", p.Name()) +} 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/identity.go b/pkg/mcp/cloud/providers/aws/identity.go new file mode 100644 index 00000000..43d478e9 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/identity.go @@ -0,0 +1,101 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +// envExpectedRoleARN optionally pins the IAM role ARN the assumed-role caller +// must resolve to. When set, Identity rejects any caller whose underlying role +// does not match it, the strict check. When unset, Identity falls back to the +// structural check (the caller must be an assumed-role ARN at all, proving the +// AWS_PROFILE assume-role pin took effect rather than the operator's plain base +// identity leaking through). +const envExpectedRoleARN = "TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN" + +// 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 TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN set, the +// caller's underlying role must match it exactly. 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. +func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (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, os.Getenv(envExpectedRoleARN)) + 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:aws:sts:::assumed-role//; the IAM role +// it stands for is arn:aws:iam:::role/. +func assumedRoleARN(arn string) (string, bool) { + const prefix = "arn:aws:sts::" + const marker = ":assumed-role/" + if !strings.HasPrefix(arn, prefix) { + return "", false + } + idx := strings.Index(arn, marker) + if idx < 0 { + return "", false + } + account := arn[len(prefix):idx] + rest := arn[idx+len(marker):] + roleName, _, found := strings.Cut(rest, "/") + if !found || roleName == "" || account == "" { + return "", false + } + return fmt.Sprintf("arn:aws:iam::%s:role/%s", account, roleName), true +} 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..3059de1b --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/identity_test.go @@ -0,0 +1,106 @@ +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) { + t.Setenv(envExpectedRoleARN, "arn:aws:iam::111122223333:role/triagent-readonly") + 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.True(t, st.Valid, "assumed-role ARN whose role matches the pinned expectation is valid") +} + +func TestIdentityRejectsMismatchedExpectedRoleArn(t *testing.T) { + t.Setenv(envExpectedRoleARN, "arn:aws:iam::111122223333:role/some-other-role") + 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.False(t, st.Valid, "assumed role not matching the pinned expectation is invalid") + assert.NotEmpty(t, st.Hint) +} + +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..6f761644 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/inventory.go @@ -0,0 +1,68 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +// listAccountsResult is the projection of `aws organizations list-accounts +// --output json`. Only the fields inventory needs are decoded. +type listAccountsResult struct { + Accounts []organizationsAccount `json:"Accounts"` +} + +type organizationsAccount struct { + ID string `json:"Id"` + Name string `json:"Name"` + Status string `json:"Status"` +} + +// Inventory projects the AWS accounts the pinned identity can read. The primary +// source is `aws organizations list-accounts`; when the identity lacks +// Organizations access (AccessDenied, surfaced as a non-zero exit or a transport +// error) it falls back to the single account the caller is in, derived from `aws +// sts get-caller-identity`. Both commands are allowlisted so the projection works +// under the validated run core. +func (p *Provider) Inventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { + res, err := run(ctx, []string{"organizations", "list-accounts", "--output", "json"}) + if err != nil || res.ExitCode != 0 { + return p.callerAccountInventory(ctx, run) + } + + var parsed listAccountsResult + if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil { + return cloud.Inventory{}, fmt.Errorf("parse organizations list-accounts: %w", err) + } + + scopes := make([]cloud.Scope, 0, len(parsed.Accounts)) + for _, a := range parsed.Accounts { + if a.Status != "ACTIVE" { + continue + } + scopes = append(scopes, cloud.Scope{ID: a.ID, Name: a.Name}) + } + return cloud.Inventory{Scopes: scopes}, nil +} + +// callerAccountInventory derives the single-account inventory from the caller +// identity, the fallback when Organizations access is denied. +func (p *Provider) callerAccountInventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { + res, err := run(ctx, []string{"sts", "get-caller-identity", "--output", "json"}) + if err != nil { + return cloud.Inventory{}, fmt.Errorf("caller identity for inventory fallback: %w", err) + } + if res.ExitCode != 0 { + return cloud.Inventory{}, fmt.Errorf("aws sts get-caller-identity failed (exit %d)", res.ExitCode) + } + var caller callerIdentity + if err := json.Unmarshal([]byte(res.Stdout), &caller); err != nil { + return cloud.Inventory{}, fmt.Errorf("parse caller identity for inventory fallback: %w", err) + } + if caller.Account == "" { + return cloud.Inventory{}, fmt.Errorf("caller identity has no account") + } + return cloud.Inventory{Scopes: []cloud.Scope{{ID: caller.Account, Name: caller.Account}}}, 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..56b3c751 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/inventory_test.go @@ -0,0 +1,71 @@ +package aws + +import ( + "context" + "testing" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const listAccountsOutput = `{ + "Accounts": [ + { "Id": "111122223333", "Name": "prod", "Status": "ACTIVE" }, + { "Id": "444455556666", "Name": "staging", "Status": "ACTIVE" }, + { "Id": "777788889999", "Name": "suspended-acct", "Status": "SUSPENDED" } + ] +}` + +func TestInventoryProjectsActiveAccounts(t *testing.T) { + f := &fakeRun{results: map[string]cloud.CLIResult{ + "organizations list-accounts": {Stdout: listAccountsOutput}, + }} + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + inv, err := p.Inventory(context.Background(), f.run) + require.NoError(t, err) + + require.Len(t, f.calls, 1) + assert.Equal(t, []string{"organizations", "list-accounts", "--output", "json"}, f.calls[0]) + + require.Len(t, inv.Scopes, 2, "suspended accounts are dropped") + assert.Equal(t, cloud.Scope{ID: "111122223333", Name: "prod"}, inv.Scopes[0]) + assert.Equal(t, cloud.Scope{ID: "444455556666", Name: "staging"}, inv.Scopes[1]) +} + +func TestInventoryFallsBackToCallerAccountOnAccessDenied(t *testing.T) { + f := &fakeRun{ + results: map[string]cloud.CLIResult{ + "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, + }, + errs: map[string]error{ + "organizations list-accounts": errAccessDenied, + }, + } + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + inv, err := p.Inventory(context.Background(), f.run) + require.NoError(t, err) + + require.Len(t, inv.Scopes, 1, "no orgs access falls back to the single caller account") + assert.Equal(t, "111122223333", inv.Scopes[0].ID) +} + +func TestInventoryFallsBackOnAccessDeniedExitCode(t *testing.T) { + f := &fakeRun{ + results: map[string]cloud.CLIResult{ + "organizations list-accounts": {ExitCode: 254, Stdout: "An error occurred (AccessDeniedException) when calling the ListAccounts operation: ..."}, + "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, + }, + } + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + inv, err := p.Inventory(context.Background(), f.run) + require.NoError(t, err) + require.Len(t, inv.Scopes, 1) + assert.Equal(t, "111122223333", inv.Scopes[0].ID) +} diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go new file mode 100644 index 00000000..018f4cf0 --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -0,0 +1,104 @@ +// 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" + "fmt" + "os/exec" + + "github.com/sourcehawk/triagent/pkg/mcp/cloud" +) + +//go:embed default_commands.json +var defaultCommandsJSON []byte + +// 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. + +// Provider is the AWS realization of cloud.Provider. binary is resolved once at +// construction (overridable in tests); allowlist is the parsed embedded default. +type Provider struct { + binary string + allowlist *cloud.CommandAllowlist +} + +// 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. +func New() (*Provider, error) { + bin, err := exec.LookPath("aws") + if err != nil { + return nil, fmt.Errorf("aws: resolve aws binary: %w", err) + } + return newWithBinary(bin) +} + +// newWithBinary builds the provider against an already-resolved binary path. It +// is the seam tests inject a fixed path through, bypassing exec.LookPath. +func newWithBinary(binary string) (*Provider, error) { + var list cloud.CommandAllowlist + if err := json.Unmarshal(defaultCommandsJSON, &list); err != nil { + return nil, fmt.Errorf("aws: parse default allowlist: %w", err) + } + return &Provider{binary: binary, allowlist: &list}, nil +} + +// 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 +// credential material or shell access beyond the base floor. The base floor +// already covers the secrets/ssh/auth/config families and identity flags; these +// add the credential-returning reads unique to AWS. +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", + }, + } +} + +// 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{ + "AWS_PROFILE", + "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..18a3b42b --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/provider_test.go @@ -0,0 +1,117 @@ +package aws + +import ( + "context" + "errors" + "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()) +} + +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 (identity + inventory fallback)") + assert.True(t, allow.Allows([]string{"organizations", "list-accounts"}), + "organizations list-accounts must be allowlisted (inventory primary)") + + // 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") +} + +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 +} + +var errAccessDenied = errors.New("access denied (AccessDeniedException) when calling the ListAccounts operation") From 5aa093dd653161932ccc4d9dec3671ad1362bbe4 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 05:39:49 +0200 Subject: [PATCH 12/76] chore(state): #46 self-merged (#50); Wave 2a complete Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index b27ac8cc..97035f6a 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -24,7 +24,7 @@ status: developing | ----- | ------ | ------------- | ----------- | ------ | | #45 — scaffold + harness | (merged, branch deleted) | (removed) | #48 → feature/cloud-context-mcp | self-merged | | #43 — GCP provider | (merged, branch deleted) | (removed) | #49 → feature/cloud-context-mcp | self-merged | -| #46 — AWS provider | feature/cloud-context-mcp--aws | .claude/worktrees/cloud-context-mcp--aws | _tbd_ → feature/cloud-context-mcp | dispatched | +| #46 — AWS provider | (merged, branch deleted) | (removed) | #50 → feature/cloud-context-mcp | self-merged | | #47 — launcher integration | _tbd (Wave 2b)_ | _tbd (Wave 2b)_ | _tbd_ → feature/cloud-context-mcp | blocked (Wave 2b: needs #43 + #46 merged) | ## Contracts From 72c9afa59a52f04ed84a653785f1df1fc692a424 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 05:43:43 +0200 Subject: [PATCH 13/76] chore(state): Wave 2a checkpoint clean; dispatch Wave 2b (#47 + probe-env) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../states/2026-05-30-cloud-context-mcp-state.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index 97035f6a..571bab64 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -15,8 +15,8 @@ status: developing ## Phases - **Phase 1 (foundational)** — `#45` (scaffold + harness; produces every contract). **Done** — self-merged as #48. -- **Phase 2a (providers, parallel)** — `#43` (GCP provider), `#46` (AWS provider). In flight. -- **Phase 2b (launcher, gated)** — `#47` (launcher integration). Gated on **both** #43 and #46 self-merging: its preflight + connections probe constructs `cloud.Provider` values to call `cloud.Probe`, so it imports the provider packages (see Bubble-up log). Dispatched only after 2a merges and the shared provider factory exists. +- **Phase 2a (providers, parallel)** — `#43` (GCP provider), `#46` (AWS provider). **Done** — self-merged as #49 / #50. Wave-boundary checkpoint clean: e2e `make test-go` + `make lint` green; coherence sweep found no align-now drift (identical provider layout/constructor/idiom; two differences both deliberate-justified by the gcp-impersonation vs aws-assume-role mechanisms). +- **Phase 2b (parallel)** — `#47` (launcher integration; builds the shared provider factory + profile/mcpconfig/preflight/connections/frontend) and a **probe-env remediation** sub-PR (parent `cloud` package: make `cloud.Probe` use the minimal env, per Bubble-up log). Disjoint file sets (`#47`: factory + `cmd/.../serve.go` + `internal/*` + frontend; probe-env: `pkg/mcp/cloud/probe.go` + `server.go`), so they run concurrently. ## PRs / worktrees @@ -25,7 +25,8 @@ status: developing | #45 — scaffold + harness | (merged, branch deleted) | (removed) | #48 → feature/cloud-context-mcp | self-merged | | #43 — GCP provider | (merged, branch deleted) | (removed) | #49 → feature/cloud-context-mcp | self-merged | | #46 — AWS provider | (merged, branch deleted) | (removed) | #50 → feature/cloud-context-mcp | self-merged | -| #47 — launcher integration | _tbd (Wave 2b)_ | _tbd (Wave 2b)_ | _tbd_ → feature/cloud-context-mcp | blocked (Wave 2b: needs #43 + #46 merged) | +| #47 — launcher integration | feature/cloud-context-mcp--launcher | .claude/worktrees/cloud-context-mcp--launcher | _tbd_ → feature/cloud-context-mcp | dispatched | +| probe-env remediation (epic #44) | feature/cloud-context-mcp--probe-env | .claude/worktrees/cloud-context-mcp--probe-env | _tbd_ → feature/cloud-context-mcp | dispatched | ## Contracts From 16aca725c539f989623cb1323afb12957b3be838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 05:54:38 +0200 Subject: [PATCH 14/76] fix(cloud): probe with a minimal subprocess env instead of inheriting the parent (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe built its RunFunc with a nil cmd.Env, which makes the gcloud/aws whoami subprocess inherit the entire launcher environment — leaking ambient secrets into the identity probe used by session_status, preflight, and connections. This contradicted the spec's "explicit minimal cmd.Env" requirement and diverged from Server.run, which already filters the env. Extract a package-level minimalEnv helper (os.Environ filtered to the base passthrough plus the provider-declared names) so both the run_cli harness and the probe build their subprocess env through one home. Server.subprocessEnv now delegates to it, and Probe forwards minimalEnv(p.EnvPassthrough()) instead of nil — the whoami still gets the credential/impersonation env it needs, nothing more. Co-authored-by: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/probe.go | 3 +- pkg/mcp/cloud/probe_test.go | 62 +++++++++++++++++++++++++++++++++++++ pkg/mcp/cloud/server.go | 13 ++++++-- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/pkg/mcp/cloud/probe.go b/pkg/mcp/cloud/probe.go index 54e722d6..fecac471 100644 --- a/pkg/mcp/cloud/probe.go +++ b/pkg/mcp/cloud/probe.go @@ -12,8 +12,9 @@ import "context" // 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) (IdentityStatus, error) { + env := minimalEnv(p.EnvPassthrough()) run := func(ctx context.Context, argv []string) (CLIResult, error) { - return execCLI(ctx, p.Binary(), argv, nil, defaultOutputLimit) + return execCLI(ctx, p.Binary(), argv, env, defaultOutputLimit) } st, err := p.Identity(ctx, run) diff --git a/pkg/mcp/cloud/probe_test.go b/pkg/mcp/cloud/probe_test.go index e94aa390..8c7df391 100644 --- a/pkg/mcp/cloud/probe_test.go +++ b/pkg/mcp/cloud/probe_test.go @@ -3,12 +3,44 @@ package cloud import ( "context" "errors" + "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) Identity(ctx context.Context, run RunFunc) (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{ @@ -35,6 +67,36 @@ func TestProbeSurfacesProviderErrorAsInvalid(t *testing.T) { assert.NotEmpty(t, st.Hint, "expected the provider error surfaced as a hint") } +// TestProbeUsesMinimalSubprocessEnv proves the probe path forwards only the +// base passthrough plus the provider's declared names to the whoami subprocess, +// dropping the launcher's ambient secrets. A parent canary must not cross the +// boundary while a declared passthrough var survives. +func TestProbeUsesMinimalSubprocessEnv(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 := &envProbeProvider{ + name: "gcp", + envPassthrough: []string{"CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"}, + } + + st, err := Probe(context.Background(), p) + require.NoError(t, err) + + seen := st.AssumedIdentity + assert.NotContains(t, seen, "TRIAGENT_CLOUD_LEAK_CANARY", + "parent-env secret must not reach the probe subprocess") + assert.Contains(t, seen, "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=ro-sa@proj.iam.gserviceaccount.com", + "declared passthrough var must reach the probe subprocess") + for _, line := range strings.Split(seen, "\n") { + if line == "" { + continue + } + name, _, _ := strings.Cut(line, "=") + assert.Contains(t, []string{"PATH", "HOME", "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"}, name, + "only base + declared passthrough names may cross the boundary") + } +} + func TestProbeInvalidWhenIdentityEmpty(t *testing.T) { t.Parallel() p := &fakeProvider{name: "gcp", identity: IdentityStatus{Provider: "gcp", Valid: true}} diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index 21f65497..3e2d8a66 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -98,11 +98,20 @@ func (s *Server) run(ctx context.Context, argv []string) (CLIResult, error) { // names, read from the launcher-controlled process env. Everything else is // dropped, so the launcher's ambient secrets never reach the CLI. func (s *Server) subprocessEnv() []string { - keep := make(map[string]bool, len(baseEnvPassthrough)) + return minimalEnv(s.provider.EnvPassthrough()) +} + +// 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 s.provider.EnvPassthrough() { + for _, name := range passthrough { keep[name] = true } var env []string From 60185f35efa5fe3a8c135a3ccc8384eeb598cca2 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 05:55:10 +0200 Subject: [PATCH 15/76] chore(state): probe-env remediation self-merged (#51) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index 571bab64..c4739908 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -26,7 +26,7 @@ status: developing | #43 — GCP provider | (merged, branch deleted) | (removed) | #49 → feature/cloud-context-mcp | self-merged | | #46 — AWS provider | (merged, branch deleted) | (removed) | #50 → feature/cloud-context-mcp | self-merged | | #47 — launcher integration | feature/cloud-context-mcp--launcher | .claude/worktrees/cloud-context-mcp--launcher | _tbd_ → feature/cloud-context-mcp | dispatched | -| probe-env remediation (epic #44) | feature/cloud-context-mcp--probe-env | .claude/worktrees/cloud-context-mcp--probe-env | _tbd_ → feature/cloud-context-mcp | dispatched | +| probe-env remediation (epic #44) | (merged, branch deleted) | (removed) | #51 → feature/cloud-context-mcp | self-merged | ## Contracts From 9f8c72f1f01ca1d41220d648d1e0fadd80560b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 06:15:48 +0200 Subject: [PATCH 16/76] feat(cloud): launcher integration for the cloud-context MCP (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cloud): shared provider factory; serve.go delegates construction (#47) Introduce pkg/mcp/cloud/providers.New(name), the single construction site for a cloud.Provider. It imports the concrete gcp and aws packages (which the cloud package itself cannot, without a cycle) and mirrors how the launcher builds an auth.Provider from pkg/auth/teleport and pkg/auth/kubeconfig. serve.go's newCloudProvider is removed in favour of delegating to the factory, so preflight and connections can obtain a provider the same way the serve arm does. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(profile): cloud source config block (#47) Add Profile.Cloud []CloudSource so a deployment can declare read-only cloud connections in the profile. Each source carries the alias, provider, pinned AssumedIdentity, optional aws Profile selector, scope allowlist, and an optional command-allowlist override path. applyBase inherits cloud sources with the same replace-on-presence rule as linked_repos. AssumedIdentity is the canonical pinned identity (SA email for gcp, role ARN for aws); Profile is the aws-only AWS_PROFILE selector, ignored by gcp. Scope reuses cloud.ScopeAllowlist so the launcher can JSON-encode it into the cloud MCP subprocess env unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(preflight): wire triagent-cloud- servers with pinned-identity env (#47) Emit a triagent-cloud- MCP server per profile cloud source, with args ["serve","--kind=cloud","--provider=

"] and env carrying the provider selector, the optional allowlist-override path, the JSON-encoded scope, and the per-provider pinned-identity env. The cloud loop mirrors the per-repo git loop. Per-provider identity env, by mechanism: gcp impersonates the assumed identity directly via CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT (one env is both the impersonation target and the expected identity); aws selects an assume-role profile via AWS_PROFILE and checks the role ARN via TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN. mcpconfig references the env-name constants from the provider packages, never raw literals — so the gcp impersonation const and the aws profile/expected-role consts are exported. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(preflight): cloud identity probe with visible degrade (#47) Run the read-only identity probe for each profile cloud source after the kubeconfig freeze, recording the outcome in Result.CloudSources. The probe degrades, never blocks: a failed probe — or a provider construction error, e.g. a missing CLI binary — marks that source unavailable with a hint, and the session still starts. The existing k8s block-on-failure behaviour is unchanged. The shared providers.ProbeSource constructs the source's provider via the factory and pins the per-provider expected-identity env around the whoami (serialized, then restored) so each probe validates against its own pinned identity. Preflight exposes a CloudProbe seam for tests; nil uses the real prober. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(connections): read-only cloud identity status in /api/connections (#47) GET /api/connections grows a cloud array of {provider, assumed_identity, valid, hint}, built from the profile's cloud sources probed at request time. The fields mirror cloud.IdentityStatus so the panel renders directly from the probe. Read-only: no PUT/DELETE route for cloud, since a cloud connection is configured in the profile, not entered in the panel. The cloud array is profile-sourced (the connections wallet holds only stored tokens, which cloud has none of), so it lives on the response builder beside slack_channel_prefix rather than in the connections package. cloudProbe is an injectable seam; nil uses the real providers.ProbeSource. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): read-only cloud identity pills in connections panel (#47) Render the profile-configured cloud connections as read-only pills in the manage-connections modal: the assumed identity with a checkmark when the request-time probe is valid, the reauth hint when not. Cloud is configured in the deployment profile, never entered in the panel, so the pills carry no edit affordance. The section is omitted when no cloud sources exist. ConnectionStatus grows an optional cloud[] of {provider, assumed_identity, valid, hint}, mirroring the /api/connections response. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 19 +-- cmd/triagent-mcp/serve_cloud_test.go | 8 -- frontend/components/ConnectionsPanel.test.tsx | 112 ++++++++++++++++++ frontend/components/ConnectionsPanel.tsx | 73 +++++++++++- frontend/components/Icons.tsx | 2 + frontend/lib/api.ts | 14 +++ internal/preflight/mcpconfig.go | 60 ++++++++++ internal/preflight/mcpconfig_test.go | 84 +++++++++++++ internal/preflight/preflight.go | 64 ++++++++++ internal/preflight/preflight_test.go | 61 ++++++++++ internal/profile/cloud_base_test.go | 40 +++++++ internal/profile/embed.go | 10 ++ internal/profile/profile.go | 25 ++++ internal/profile/profile_test.go | 43 +++++++ internal/server/handlers.go | 6 + internal/server/handlers_connections.go | 73 ++++++++++-- internal/server/handlers_connections_test.go | 61 ++++++++++ pkg/mcp/cloud/providers/aws/identity.go | 6 +- pkg/mcp/cloud/providers/aws/identity_test.go | 4 +- pkg/mcp/cloud/providers/aws/provider.go | 9 +- pkg/mcp/cloud/providers/gcp/identity.go | 4 +- pkg/mcp/cloud/providers/gcp/identity_test.go | 12 +- pkg/mcp/cloud/providers/gcp/provider.go | 6 +- pkg/mcp/cloud/providers/probe.go | 87 ++++++++++++++ pkg/mcp/cloud/providers/registry.go | 32 +++++ pkg/mcp/cloud/providers/registry_test.go | 45 +++++++ 26 files changed, 908 insertions(+), 52 deletions(-) create mode 100644 frontend/components/ConnectionsPanel.test.tsx create mode 100644 internal/profile/cloud_base_test.go create mode 100644 pkg/mcp/cloud/providers/probe.go create mode 100644 pkg/mcp/cloud/providers/registry.go create mode 100644 pkg/mcp/cloud/providers/registry_test.go diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index 112e6854..6308f945 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -12,8 +12,7 @@ import ( "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/aws" - "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers" "github.com/sourcehawk/triagent/pkg/mcp/git" "github.com/sourcehawk/triagent/pkg/mcp/incidentio" "github.com/sourcehawk/triagent/pkg/mcp/k8s" @@ -446,7 +445,7 @@ 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) } - provider, err := newCloudProvider(f.cloudProvider) + provider, err := providers.New(f.cloudProvider) if err != nil { return err } @@ -462,20 +461,6 @@ func runCloud(ctx context.Context, f serveFlags) error { return srv.Run(ctx) } -// newCloudProvider constructs the cloud.Provider for the named provider. Each -// implementation lives in pkg/mcp/cloud/providers/; an unknown provider is -// named in the error. -func newCloudProvider(name string) (cloud.Provider, error) { - switch name { - case "gcp": - return gcp.New() - case "aws": - return aws.New() - default: - return nil, fmt.Errorf("unknown cloud --provider %q (want gcp or aws)", name) - } -} - // 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 logged and treated the same, diff --git a/cmd/triagent-mcp/serve_cloud_test.go b/cmd/triagent-mcp/serve_cloud_test.go index e398968d..32504a07 100644 --- a/cmd/triagent-mcp/serve_cloud_test.go +++ b/cmd/triagent-mcp/serve_cloud_test.go @@ -34,11 +34,3 @@ func TestServeCmd_KnowsCloudKind(t *testing.T) { cmd := serveCmd() assert.Contains(t, cmd.Long, "cloud", "serve --help should list cloud") } - -func TestNewCloudProvider_AWSIsBuilt(t *testing.T) { - t.Parallel() - p, err := newCloudProvider("aws") - require.NoError(t, err) - require.NotNil(t, p) - assert.Equal(t, "aws", p.Name()) -} diff --git a/frontend/components/ConnectionsPanel.test.tsx b/frontend/components/ConnectionsPanel.test.tsx new file mode 100644 index 00000000..66e0c116 --- /dev/null +++ b/frontend/components/ConnectionsPanel.test.tsx @@ -0,0 +1,112 @@ +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 a read-only cloud pill per entry with the assumed identity", async () => { + vi.spyOn(api, "getConnections").mockResolvedValue({ + ...baseStatus, + cloud: [ + { + provider: "gcp", + assumed_identity: "triage-ro@prod.iam.gserviceaccount.com", + valid: true, + }, + { + provider: "aws", + assumed_identity: "arn:aws:iam::1:role/triage-ro", + valid: false, + hint: "run: aws sso login", + }, + ], + }); + + await renderPanelAndOpenModal(); + + expect( + await screen.findByText("triage-ro@prod.iam.gserviceaccount.com"), + ).toBeInTheDocument(); + expect( + screen.getByText("arn:aws:iam::1:role/triage-ro"), + ).toBeInTheDocument(); + }); + + it("shows the reauth hint only for an invalid source", async () => { + vi.spyOn(api, "getConnections").mockResolvedValue({ + ...baseStatus, + cloud: [ + { + provider: "aws", + assumed_identity: "arn:aws:iam::1:role/triage-ro", + 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: [ + { + 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..8431c0b3 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/ConnectionsPanel.tsx @@ -1,9 +1,14 @@ "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 { CheckIcon, CloudIcon, IncidentIoIcon, SlackIcon } from "./Icons"; import { Spinner } from "./Spinner"; // ConnectionsPanel sits at the bottom of the sidenav next to @@ -189,7 +194,71 @@ 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 cloud identities pinned in the deployment profile. Fix a stale + credential through your own cloud login before starting a session. +

+
+ {cloud.map((c, i) => ( + + ))} +
+
+ ); +} + +function CloudPill({ conn }: { conn: CloudConnection }) { + return ( +
+
+
+ + {conn.provider} + + + {conn.assumed_identity} + +
+ {conn.valid ? ( + + + valid + + ) : ( + + unavailable + + )}
+ {!conn.valid && conn.hint && ( +
{conn.hint}
+ )}
); } diff --git a/frontend/components/Icons.tsx b/frontend/components/Icons.tsx index f4161336..3a76b620 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; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index d01ce7a8..56e12fb2 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -89,6 +89,20 @@ 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 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 = { + provider: string; + assumed_identity: string; + valid: boolean; + hint?: string; }; export type SlackChannel = { diff --git a/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index 6d78873f..c01dc5ac 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -12,6 +12,9 @@ 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/aws" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" ) var envRefRe = regexp.MustCompile(`^\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}$`) @@ -47,6 +50,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,6 +108,9 @@ 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 UserPlaybooksDir string // optional override-or-extend dir for strategies playbooks PluginPlaybooksDir string // launcher-managed clone of the upstream playbooks repo (overridable) @@ -169,6 +179,41 @@ 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, and the per-provider +// pinned-identity env. +// +// The two clouds pin identity through different env, by mechanism. GCP +// impersonates the assumed identity directly, so a single env +// (CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT) is both the impersonation target +// and the expected identity. AWS selects an assume-role profile (AWS_PROFILE) +// for credentials and checks the role ARN (TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN) +// for strict validity, so it needs both a profile selector and the expected ARN. +// The env-name constants come from the provider packages, never raw literals. +func cloudSourceEnv(src profile.CloudSource) (map[string]string, error) { + env := map[string]string{ + cloud.EnvProvider: src.Provider, + } + 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 + case "aws": + env[aws.EnvProfile] = src.Profile + env[aws.EnvExpectedRoleARN] = src.AssumedIdentity + } + return env, nil +} + // 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 +359,21 @@ func writeMCPConfig(in mcpConfigInputs) (string, error) { } } + for _, src := range in.CloudSources { + alias := MCPAliasCloudPrefix + src.Alias + cloudEnv, err := cloudSourceEnv(src) + 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..4b0f8334 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,84 @@ 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", + Scope: cloud.ScopeAllowlist{Projects: []string{"prod-a"}}, + 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]) + // gcp impersonates the assumed identity directly; that one env is both + // the impersonation target and the expected identity. + 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) + assert.NotContains(t, env, aws.EnvExpectedRoleARN) + + rawScope, _ := env[cloud.EnvScope].(string) + require.NotEmpty(t, rawScope, "scope must be JSON-encoded into the env") + var scope cloud.ScopeAllowlist + require.NoError(t, json.Unmarshal([]byte(rawScope), &scope)) + assert.Equal(t, []string{"prod-a"}, scope.Projects) +} + +func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithProfileAndExpectedRole(t *testing.T) { + t.Parallel() + in := baseInputs(t) + in.CloudSources = []profile.CloudSource{{ + Alias: "prod-aws", + Provider: "aws", + AssumedIdentity: "arn:aws:iam::123456789012:role/triage-ro", + Profile: "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 needs BOTH a profile selector and the expected role ARN. + assert.Equal(t, "triage-ro", env[aws.EnvProfile]) + assert.Equal(t, "arn:aws:iam::123456789012:role/triage-ro", env[aws.EnvExpectedRoleARN]) + // gcp impersonation env must not leak onto an aws source. + assert.NotContains(t, env, gcp.EnvImpersonate) +} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 9a438155..9610cf72 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -18,6 +18,8 @@ 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" ) // Options describes a single preflight invocation. @@ -109,6 +111,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 +133,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 @@ -158,6 +178,7 @@ func Run(opts Options) (*Result, error) { KubeconfigPath: kubeconfigPath, Profile: opts.Profile, LinkedRepos: opts.LinkedRepos, + CloudSources: cloudSources(opts.Profile), GitCacheDir: opts.GitCacheDir, UserPlaybooksDir: opts.UserPlaybooksDir, PluginPlaybooksDir: opts.PluginPlaybooksDir, @@ -185,9 +206,52 @@ func Run(opts Options) (*Result, error) { MCPConfigPath: mcpPath, DocsPrefix: docsPrefix, KubeconfigPath: kubeconfigPath, + CloudSources: probeCloudSources(opts.Ctx, cloudSources(opts.Profile), opts.CloudProbe), }, nil } +// 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 +} + +// defaultCloudProbe is the real prober: it maps a profile cloud source to the +// providers package's neutral Source 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. +func defaultCloudProbe(ctx context.Context, src profile.CloudSource) cloud.IdentityStatus { + return providers.ProbeSource(ctx, providers.Source{ + Provider: src.Provider, + AssumedIdentity: src.AssumedIdentity, + Profile: src.Profile, + }) +} + +// 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..17eb8163 100644 --- a/internal/preflight/preflight_test.go +++ b/internal/preflight/preflight_test.go @@ -12,7 +12,9 @@ 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" ) // fakeProvider lets the preflight gate be tested without a real tsh session. @@ -224,3 +226,62 @@ 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", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "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) +} + +// 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) +} diff --git a/internal/profile/cloud_base_test.go b/internal/profile/cloud_base_test.go new file mode 100644 index 00000000..334e374f --- /dev/null +++ b/internal/profile/cloud_base_test.go @@ -0,0 +1,40 @@ +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", AssumedIdentity: "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..c83968fd 100644 --- a/internal/profile/embed.go +++ b/internal/profile/embed.go @@ -291,6 +291,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 +310,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/profile.go b/internal/profile/profile.go index ea6579ba..3ea4474c 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,28 @@ 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. +// +// AssumedIdentity is the canonical pinned identity shown in the connections +// panel — a service-account email for gcp, a role ARN for aws. The two clouds +// realize it through different env: gcp impersonates AssumedIdentity directly, +// while aws selects an assume-role profile (Profile) for credentials and checks +// AssumedIdentity (the role ARN) for strict validity. Profile is therefore +// aws-only; gcp ignores it. +type CloudSource struct { + Alias string `yaml:"alias"` + Provider string `yaml:"provider"` // "gcp" | "aws" + AssumedIdentity string `yaml:"assumed_identity"` + Profile string `yaml:"profile,omitempty"` // aws AWS_PROFILE selector; ignored by gcp + 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. + CommandAllowlistPath string `yaml:"command_allowlist_path,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..7e417cc3 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -662,3 +662,46 @@ 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 + scope: + projects: + - prod-a + - prod-b + command_allowlist_path: /etc/triagent/gcp-allow.json + - alias: prod-aws + provider: aws + assumed_identity: arn:aws:iam::123456789012:role/triage-ro + profile: triage-ro + 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) + assert.Equal(t, []string{"prod-a", "prod-b"}, gcp.Scope.Projects) + 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.Equal(t, "arn:aws:iam::123456789012:role/triage-ro", aws.AssumedIdentity) + assert.Equal(t, "triage-ro", aws.Profile) + assert.Equal(t, []string{"us-east-1"}, aws.Scope.Regions) +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index b96c3ba9..b53e0094 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 diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index 51dcc622..61300c87 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,9 @@ import ( "time" "github.com/sourcehawk/triagent/internal/connections" + "github.com/sourcehawk/triagent/internal/profile" + "github.com/sourcehawk/triagent/pkg/mcp/cloud" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers" ) // Connection-management endpoints. The panel reads /api/connections to @@ -25,12 +29,25 @@ 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: 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. +type cloudConnection struct { + Provider string `json:"provider"` + AssumedIdentity string `json:"assumed_identity"` + 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 +55,51 @@ 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 nil when no profile or no +// cloud sources are configured. 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 nil + } + probe := a.cloudProbe + if probe == nil { + probe = defaultCloudProbe + } + out := make([]cloudConnection, 0, len(a.prof.Cloud)) + for _, src := range a.prof.Cloud { + st := probe(ctx, src) + out = append(out, cloudConnection{ + Provider: st.Provider, + AssumedIdentity: st.AssumedIdentity, + Valid: st.Valid, + Hint: st.Hint, + }) + } + return out +} + +// defaultCloudProbe is the real request-time prober: it maps a profile cloud +// source to the providers package's neutral Source and runs ProbeSource, which +// constructs the provider and shells its whoami CLI, degrading a construction +// error to an invalid status. +func defaultCloudProbe(ctx context.Context, src profile.CloudSource) cloud.IdentityStatus { + return providers.ProbeSource(ctx, providers.Source{ + Provider: src.Provider, + AssumedIdentity: src.AssumedIdentity, + Profile: src.Profile, + }) +} + // 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 +107,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 +137,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 +165,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 +181,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..23f41d17 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,62 @@ 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"}, + {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "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", AssumedIdentity: src.AssumedIdentity, 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 { + Provider string `json:"provider"` + AssumedIdentity string `json:"assumed_identity"` + 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) + assert.Equal(t, "ro@p.iam.gserviceaccount.com", resp.Cloud[0].AssumedIdentity) + assert.True(t, resp.Cloud[0].Valid) + + 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) +} + +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) + + var resp struct { + Cloud []json.RawMessage `json:"cloud"` + } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.Empty(t, resp.Cloud, "no cloud sources means an empty cloud array") +} diff --git a/pkg/mcp/cloud/providers/aws/identity.go b/pkg/mcp/cloud/providers/aws/identity.go index 43d478e9..415017f7 100644 --- a/pkg/mcp/cloud/providers/aws/identity.go +++ b/pkg/mcp/cloud/providers/aws/identity.go @@ -10,13 +10,13 @@ import ( "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) -// envExpectedRoleARN optionally pins the IAM role ARN the assumed-role caller +// EnvExpectedRoleARN optionally pins the IAM role ARN the assumed-role caller // must resolve to. When set, Identity rejects any caller whose underlying role // does not match it, the strict check. When unset, Identity falls back to the // structural check (the caller must be an assumed-role ARN at all, proving the // AWS_PROFILE assume-role pin took effect rather than the operator's plain base // identity leaking through). -const envExpectedRoleARN = "TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN" +const EnvExpectedRoleARN = "TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN" // callerIdentity is the projection of `aws sts get-caller-identity --output // json`. Only the fields the probe and inventory fallback use are decoded. @@ -59,7 +59,7 @@ func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (cloud.Ident } st := cloud.IdentityStatus{Provider: "aws", AssumedIdentity: caller.Arn} - st.Valid, st.Hint = evaluateIdentity(caller.Arn, os.Getenv(envExpectedRoleARN)) + st.Valid, st.Hint = evaluateIdentity(caller.Arn, os.Getenv(EnvExpectedRoleARN)) return st, nil } diff --git a/pkg/mcp/cloud/providers/aws/identity_test.go b/pkg/mcp/cloud/providers/aws/identity_test.go index 3059de1b..d546bf99 100644 --- a/pkg/mcp/cloud/providers/aws/identity_test.go +++ b/pkg/mcp/cloud/providers/aws/identity_test.go @@ -66,7 +66,7 @@ func TestIdentityInvalidWhenNotAssumedRole(t *testing.T) { } func TestIdentityMatchesExpectedRoleArnWhenPinned(t *testing.T) { - t.Setenv(envExpectedRoleARN, "arn:aws:iam::111122223333:role/triagent-readonly") + t.Setenv(EnvExpectedRoleARN, "arn:aws:iam::111122223333:role/triagent-readonly") f := &fakeRun{results: map[string]cloud.CLIResult{ "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, }} @@ -79,7 +79,7 @@ func TestIdentityMatchesExpectedRoleArnWhenPinned(t *testing.T) { } func TestIdentityRejectsMismatchedExpectedRoleArn(t *testing.T) { - t.Setenv(envExpectedRoleARN, "arn:aws:iam::111122223333:role/some-other-role") + t.Setenv(EnvExpectedRoleARN, "arn:aws:iam::111122223333:role/some-other-role") f := &fakeRun{results: map[string]cloud.CLIResult{ "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, }} diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go index 018f4cf0..c7dcc0f6 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -22,6 +22,13 @@ import ( //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) @@ -95,7 +102,7 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { // supplying them as argv. PATH and HOME are forwarded by the harness base set. func (p *Provider) EnvPassthrough() []string { return []string{ - "AWS_PROFILE", + EnvProfile, "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", diff --git a/pkg/mcp/cloud/providers/gcp/identity.go b/pkg/mcp/cloud/providers/gcp/identity.go index b39d2980..8be0460f 100644 --- a/pkg/mcp/cloud/providers/gcp/identity.go +++ b/pkg/mcp/cloud/providers/gcp/identity.go @@ -22,7 +22,7 @@ type authAccount struct { // CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT. A degraded auth state surfaces // through Valid and Hint, never a Go error. func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (cloud.IdentityStatus, error) { - target := os.Getenv(impersonationEnv) + target := os.Getenv(EnvImpersonate) res, err := run(ctx, []string{"auth", "list", "--filter=status:ACTIVE", "--format=json"}) if err != nil { @@ -44,7 +44,7 @@ func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (cloud.Ident switch { case target == "": st.Valid = false - st.Hint = "no impersonation target pinned; set " + impersonationEnv + " on the cloud MCP subprocess" + st.Hint = "no impersonation target pinned; set " + EnvImpersonate + " on the cloud MCP subprocess" case active == "": st.Valid = false st.Hint = "no active gcloud account; run: gcloud auth login" diff --git a/pkg/mcp/cloud/providers/gcp/identity_test.go b/pkg/mcp/cloud/providers/gcp/identity_test.go index f7378923..ef553ac8 100644 --- a/pkg/mcp/cloud/providers/gcp/identity_test.go +++ b/pkg/mcp/cloud/providers/gcp/identity_test.go @@ -30,7 +30,7 @@ func runReturning(out string) cloud.RunFunc { } func TestIdentityResolvesActiveAccountAsTarget(t *testing.T) { - t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) @@ -42,7 +42,7 @@ func TestIdentityResolvesActiveAccountAsTarget(t *testing.T) { } func TestIdentityInvalidWhenActiveAccountIsNotTheTarget(t *testing.T) { - t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) @@ -55,7 +55,7 @@ func TestIdentityInvalidWhenActiveAccountIsNotTheTarget(t *testing.T) { } func TestIdentityInvalidWhenNoActiveAccount(t *testing.T) { - t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) @@ -67,7 +67,7 @@ func TestIdentityInvalidWhenNoActiveAccount(t *testing.T) { } func TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { - t.Setenv(impersonationEnv, "") + t.Setenv(EnvImpersonate, "") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) @@ -78,7 +78,7 @@ func TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { } func TestIdentitySurfacesRunErrorAsHint(t *testing.T) { - t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) @@ -92,7 +92,7 @@ func TestIdentitySurfacesRunErrorAsHint(t *testing.T) { } func TestIdentityCallsAuthListWithJSONFormat(t *testing.T) { - t.Setenv(impersonationEnv, "ro-sa@proj.iam.gserviceaccount.com") + t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) diff --git a/pkg/mcp/cloud/providers/gcp/provider.go b/pkg/mcp/cloud/providers/gcp/provider.go index 245d2d32..e4d78265 100644 --- a/pkg/mcp/cloud/providers/gcp/provider.go +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -21,11 +21,11 @@ import ( //go:embed default_commands.json var defaultCommandsJSON []byte -// impersonationEnv is the env var the launcher sets to pin the read-only +// 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 impersonationEnv = "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT" +const EnvImpersonate = "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT" var _ cloud.Provider = (*Provider)(nil) @@ -82,7 +82,7 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { // HOME are forwarded by the harness base set, so they are absent here. func (p *Provider) EnvPassthrough() []string { return []string{ - impersonationEnv, + EnvImpersonate, "CLOUDSDK_CONFIG", "CLOUDSDK_CORE_PROJECT", } diff --git a/pkg/mcp/cloud/providers/probe.go b/pkg/mcp/cloud/providers/probe.go new file mode 100644 index 00000000..4e660b0d --- /dev/null +++ b/pkg/mcp/cloud/providers/probe.go @@ -0,0 +1,87 @@ +package providers + +import ( + "context" + "os" + "sync" + + "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" +) + +// Source is a neutral description of one cloud connection to probe: the +// provider name, the pinned identity, and (aws only) the assume-role profile. +// It carries exactly what ProbeSource needs without coupling this package to +// the launcher's profile type. +type Source struct { + Provider string + AssumedIdentity string + Profile string // aws AWS_PROFILE selector; ignored by gcp +} + +// probeEnvMu serializes the env pinning ProbeSource does. The whoami probe runs +// in the launcher process, where the provider reads its expected-identity env +// via os.Getenv; ProbeSource pins that env around the probe and restores it, so +// concurrent probes for different sources cannot read each other's pin. +var probeEnvMu sync.Mutex + +// ProbeSource constructs the source's provider and runs the read-only identity +// probe, pinning the per-provider expected-identity env for the duration so the +// probe validates against this source's pinned identity. 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 { + p, err := New(src.Provider) + if err != nil { + return cloud.IdentityStatus{Provider: src.Provider, Valid: false, Hint: err.Error()} + } + + probeEnvMu.Lock() + defer probeEnvMu.Unlock() + defer pinIdentityEnv(src)() + + st, _ := cloud.Probe(ctx, p) + return st +} + +// pinIdentityEnv sets the per-provider expected-identity env for src and returns +// a restore func. gcp impersonates the assumed identity directly; aws selects an +// assume-role profile and checks the expected role ARN. The names come from the +// provider packages, never raw literals. +func pinIdentityEnv(src Source) func() { + switch src.Provider { + case "gcp": + return setEnv(map[string]string{gcp.EnvImpersonate: src.AssumedIdentity}) + case "aws": + return setEnv(map[string]string{ + aws.EnvProfile: src.Profile, + aws.EnvExpectedRoleARN: src.AssumedIdentity, + }) + default: + return func() {} + } +} + +// setEnv sets each name to its value and returns a func restoring the prior +// values (including unset for names that were absent). +func setEnv(vals map[string]string) func() { + prior := make(map[string]*string, len(vals)) + for name, val := range vals { + if old, ok := os.LookupEnv(name); ok { + prior[name] = &old + } else { + prior[name] = nil + } + _ = os.Setenv(name, val) + } + return func() { + for name, old := range prior { + if old == nil { + _ = os.Unsetenv(name) + } else { + _ = os.Setenv(name, *old) + } + } + } +} diff --git a/pkg/mcp/cloud/providers/registry.go b/pkg/mcp/cloud/providers/registry.go new file mode 100644 index 00000000..9c5feeec --- /dev/null +++ b/pkg/mcp/cloud/providers/registry.go @@ -0,0 +1,32 @@ +// 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" +) + +// New constructs the cloud.Provider for the named provider ("gcp" | "aws"). The +// concrete New() resolves the provider's CLI binary to an absolute path; 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. +func New(name string) (cloud.Provider, error) { + switch name { + case "gcp": + return gcp.New() + case "aws": + return aws.New() + 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..36371caa --- /dev/null +++ b/pkg/mcp/cloud/providers/registry_test.go @@ -0,0 +1,45 @@ +package providers + +import ( + "testing" + + "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") +} From c921b13cd4a7bde712d62801fe04694104069ec6 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 06:17:14 +0200 Subject: [PATCH 17/76] chore(state): #47 self-merged (#52); all sub-PRs merged, status=review Co-Authored-By: Claude Opus 4.8 (1M context) --- .../states/2026-05-30-cloud-context-mcp-state.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index c4739908..601398b4 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -7,7 +7,7 @@ feature_branch: feature/cloud-context-mcp feature_worktree: .claude/worktrees/cloud-context-mcp sub_pr_approval: autonomous integration_pr: -status: developing +status: review --- # Read-only cloud-context MCP (GCP and AWS) — orchestration state @@ -16,7 +16,7 @@ status: developing - **Phase 1 (foundational)** — `#45` (scaffold + harness; produces every contract). **Done** — self-merged as #48. - **Phase 2a (providers, parallel)** — `#43` (GCP provider), `#46` (AWS provider). **Done** — self-merged as #49 / #50. Wave-boundary checkpoint clean: e2e `make test-go` + `make lint` green; coherence sweep found no align-now drift (identical provider layout/constructor/idiom; two differences both deliberate-justified by the gcp-impersonation vs aws-assume-role mechanisms). -- **Phase 2b (parallel)** — `#47` (launcher integration; builds the shared provider factory + profile/mcpconfig/preflight/connections/frontend) and a **probe-env remediation** sub-PR (parent `cloud` package: make `cloud.Probe` use the minimal env, per Bubble-up log). Disjoint file sets (`#47`: factory + `cmd/.../serve.go` + `internal/*` + frontend; probe-env: `pkg/mcp/cloud/probe.go` + `server.go`), so they run concurrently. +- **Phase 2b (parallel)** — `#47` (launcher integration) and the **probe-env remediation**. **Done** — self-merged as #52 / #51. All sub-PRs merged; every sub-issue closed; only epic #44 remains, to close via the integration PR. ## PRs / worktrees @@ -25,7 +25,7 @@ status: developing | #45 — scaffold + harness | (merged, branch deleted) | (removed) | #48 → feature/cloud-context-mcp | self-merged | | #43 — GCP provider | (merged, branch deleted) | (removed) | #49 → feature/cloud-context-mcp | self-merged | | #46 — AWS provider | (merged, branch deleted) | (removed) | #50 → feature/cloud-context-mcp | self-merged | -| #47 — launcher integration | feature/cloud-context-mcp--launcher | .claude/worktrees/cloud-context-mcp--launcher | _tbd_ → feature/cloud-context-mcp | dispatched | +| #47 — launcher integration | (merged, branch deleted) | (removed) | #52 → feature/cloud-context-mcp | self-merged | | probe-env remediation (epic #44) | (merged, branch deleted) | (removed) | #51 → feature/cloud-context-mcp | self-merged | ## Contracts @@ -36,7 +36,7 @@ status: developing | `cloud-identity-probe` | stub-on-producer-branch (`cloud.Probe` + `IdentityStatus` exported by #45) | #45 (#48) | locked | | `cloud-serve-cli` | data-only (`serve --kind=cloud --provider=`) | #45 (#48) | locked | | `cloud-env-contract` | data-only (`TRIAGENT_CLOUD_*` consts in `cloud/env.go`; provider impersonation env via `Provider.EnvPassthrough() []string`) | #45 (#48), provider names in #43/#46 | locked | -| `cloud-provider-factory` | new (discovered): `pkg/mcp/cloud/providers.New(name) (cloud.Provider, error)`, importing gcp+aws; `serve.go` + `preflight` + `connections` consume it | #47 (Wave 2b) | pending | +| `cloud-provider-factory` | new (discovered): `pkg/mcp/cloud/providers.New(name) (cloud.Provider, error)`, importing gcp+aws; `serve.go` + `preflight` + `connections` consume it | #47 (#52) | locked | All four contracts landed with #45 (squash-merged as #48). Phase 2 (#43/#46/#47) is now unblocked. The `Provider` interface gained `EnvPassthrough() []string` during #45 review (see Bubble-up log) — #43/#46 must implement it, returning their CLI's credential/impersonation var names; `PATH`/`HOME` are already in the harness base set. From 3ffad0fa1007eb12296f26155f7d9f602bfe6032 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 17:07:14 +0200 Subject: [PATCH 18/76] chore(state): integration PR #53 opened Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md index 601398b4..87d492bd 100644 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md @@ -6,7 +6,7 @@ tracking_issue: #44 feature_branch: feature/cloud-context-mcp feature_worktree: .claude/worktrees/cloud-context-mcp sub_pr_approval: autonomous -integration_pr: +integration_pr: #53 status: review --- From c714e400eb78ab434706fdc748d5f5e3cc81e27b 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 17:13:43 +0200 Subject: [PATCH 19/76] chore: remove cloud-context-mcp scratch plan and state (shipped) The plan and orchestration state are scratch artifacts for the feature's development; the durable design spec stays. Removed as the final commit now that the integration PR's CI is green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-30-cloud-context-mcp.md | 422 ------------------ .../2026-05-30-cloud-context-mcp-state.md | 65 --- 2 files changed, 487 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-30-cloud-context-mcp.md delete mode 100644 docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md diff --git a/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md b/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md deleted file mode 100644 index 7e0252cf..00000000 --- a/docs/superpowers/plans/2026-05-30-cloud-context-mcp.md +++ /dev/null @@ -1,422 +0,0 @@ -# Read-only cloud-context MCP Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a read-only cloud-context MCP (`pkg/mcp/cloud/`) that gives the operator agent GCP and AWS investigation context through a thin typed surface plus a bypass-resistant gated CLI, with a deployment-pinned read-only identity the agent cannot select or escalate. - -**Architecture:** One package bound at launch by `--provider`, aliased `triagent-cloud-` (the git-MCP pattern). Provider behaviour sits behind an injectable `cloud.Provider` interface (the teleport pattern), with `gcp` and `aws` implementations in subpackages wired by `cmd/triagent-mcp/serve.go`. All cloud access shells the provider CLI through one exec core; no cloud SDK dependency. The launcher pins a read-only identity via harness-controlled env, validates it with a shared whoami probe surfaced in the connections panel and `preflight.Run()`, and degrades the cloud source visibly rather than blocking the session. - -**Tech Stack:** Go (`os/exec`, `encoding/json`, `embed`), the `modelcontextprotocol/go-sdk/mcp` server, the existing `toolspec`, `auth.Provider`, `connections.Manager`, `preflight`, and `profile` packages; Next.js for the connections panel pill. - -**Spec:** `docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md` - ---- - -## PR breakdown - -The feature lands via the feature-branch model on `feature/cloud-context-mcp`. Four sub-PRs, each its own sub-issue under epic #44: - -| PR | Issue | Scope | Depends on | -| -- | ----- | ----- | ---------- | -| **A — scaffold + harness** | #45 | `pkg/mcp/cloud/`: `Provider` interface, command allowlist + deny floor, `run_cli` harness, `list_allowed_commands`, typed `list_inventory` + `session_status` against a fake provider, the shared identity probe, `serve.go` `--kind=cloud --provider=` wiring, wire test. | — | -| **B — GCP provider** | #43 | `pkg/mcp/cloud/providers/gcp`: implements `Provider` over `gcloud`; default allowlist + deny-floor additions; impersonation env contract. | A (interface) | -| **C — AWS provider** | #46 | `pkg/mcp/cloud/providers/aws`: implements `Provider` over `aws`; default allowlist + deny-floor additions; assume-role profile contract. | A (interface) | -| **D — launcher integration** | #47 | shared provider factory `pkg/mcp/cloud/providers`; profile `cloud:` block; `mcpconfig.go` aliasing + env injection; `preflight` cloud probe + visible degrade; `connections` cloud array + `GET /api/connections`; frontend read-only pill. | A (probe), **B + C (provider construction)** | - -B and C run in parallel once A's contracts are realized. D runs **after both B and C merge**: its preflight + connections probe constructs `cloud.Provider` values to call `cloud.Probe`, so it imports the provider packages via a shared factory and cannot compile until both land. Each PR is independently reviewable and leaves `make test` green. - -## File structure - -`pkg/mcp/cloud/` (PR A): - -- `provider.go` — the `Provider` interface and the projection structs every tool returns (`Inventory`, `IdentityStatus`, `CLIResult`). -- `allowlist.go` — `Command`, `CommandAllowlist`, `LoadCommandAllowlist(path)`, and the hardcoded `denyFloor` (subcommands, flags, arg-prefixes). Mirrors `pkg/mcp/k8s/allowlist.go`. -- `harness.go` — `execCLI(ctx, binPath, argv, env, limit)`: the no-shell argv exec core with validation hooks and output truncation. -- `validate.go` — `validateArgv(argv, allow *CommandAllowlist, scope ScopeAllowlist)`: normalizes the subcommand path, checks allowlist, rejects deny-floor tokens, validates scope. -- `probe.go` — `Probe(ctx, p Provider) (IdentityStatus, error)`: the shared whoami the launcher and `session_status` both call. -- `server.go` — `Options`, `New`, `registerOn`, `Run`. `Options.Provider` is a `Provider` value (DI, teleport pattern). -- `specs.go` — `ToolSpecs()`. -- `tools_inventory.go` — `list_inventory` handler. -- `tools_status.go` — `session_status` handler. -- `tools_cli.go` — `run_cli` and `list_allowed_commands` handlers. -- `fake_test.go` — `fakeProvider` implementing `Provider` for package tests. -- `tools_wire_test.go` — asserts `ToolSpecs()` matches registered handlers (the existing wire-test convention). -- `harness_security_test.go` — the bypass-resistance assertions (no `sh -c`, metacharacters inert, deny floor, scope). - -`pkg/mcp/cloud/providers/gcp/` (PR B) and `pkg/mcp/cloud/providers/aws/` (PR C): - -- `provider.go` — the `Provider` implementation (binary name, default allowlist, deny-floor additions, env builder, projection parsers). -- `default_commands.json` — embedded default allowlist for this provider. -- `provider_test.go` — table tests over CLI-output fixtures → projections. - -Launcher (PR D): - -- `internal/profile/profile.go` — add the `Cloud` config block. -- `internal/preflight/mcpconfig.go` — `MCPAliasCloudPrefix`, cloud env constants, the cloud server entry. -- `internal/preflight/preflight.go` — cloud identity probe + degrade marking. -- `internal/connections/connections.go` — cloud status entries in the response shape. -- `internal/server/handlers_connections.go` — cloud array in `GET /api/connections`. -- `frontend/` — read-only cloud pill in the connections panel. - -## Contracts - -| Name | Producer (PR/issue) | Consumer | Shape | Realization | -| ---- | ------------------- | -------- | ----- | ----------- | -| `cloud-provider-interface` | A / #45 | B/#43, C/#46 | `cloud.Provider` Go interface (see Task A2) | stub-on-producer-branch: A's `provider.go` lands the interface + a `fakeProvider`; B/C branch from A's merged state | -| `cloud-identity-probe` | A / #45 | D / NEW | `cloud.Probe(ctx, Provider) (IdentityStatus, error)` and `IdentityStatus{Provider, AssumedIdentity, Valid, Hint string}` | stub-on-producer-branch: A exports `Probe` + `IdentityStatus`; D imports them | -| `cloud-serve-cli` | A / #45 | D / NEW | `triagent-mcp serve --kind=cloud --provider=` | data-only (CLI string) | -| `cloud-env-contract` | A+B+C | D / NEW | env var names the subprocess reads: `TRIAGENT_CLOUD_PROVIDER`, `TRIAGENT_CLOUD_ALLOWLIST_PATH`, `TRIAGENT_CLOUD_SCOPE`, plus provider impersonation env (`CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT` for gcp, `AWS_PROFILE` for aws) | data-only (exported consts in `cloud` + provider packages; D references the const names) | - -`IdentityStatus` is the single struct the probe returns; the connections array, the `session_status` tool, and the preflight gate all render from it, so they cannot disagree. - -## Conventions - -Every sub-PR inherits these (the dimensions from `feature-dev-workflow:maintaining-architectural-coherence`): - -- **Layout.** Provider implementations live in `pkg/mcp/cloud/providers//`, never in the parent package. The parent owns the interface, harness, allowlist, probe, tools; subpackages own only CLI specifics. -- **CLI-only access.** Every cloud read shells the provider binary through `cloud.execCLI`. No `cloud.google.com/go` or `aws-sdk-go` dependency in v1 — keeps auth and impersonation uniform (the CLI consumes the harness env). -- **Naming.** Server name `triagent-mcp-cloud`; session alias `triagent-cloud-`; tools `list_inventory`, `session_status`, `run_cli`, `list_allowed_commands`. The investigative groupings (inventory, reachability, permissions, cluster, logs, audit) are **axes** — used in prose and the allowlist's `Description` fields, never as Go identifiers, file names, or marker strings (the naming firewall). -- **Allowlist shape.** Provider default allowlists are `default_commands.json` embedded via `//go:embed`, loaded by the shared `LoadCommandAllowlist`, with the provider contributing deny-floor additions in code. The floor is never expressed in JSON (config can't re-enable it), mirroring how `LoadAllowlist` always filters `Secret`. -- **Output shaping.** Tools return projection structs, never raw API/CLI JSON. Redaction reuses the spirit of `pkg/mcp/k8s/redact.go`: secret-looking values are dropped, not surfaced. -- **Env discipline.** The agent supplies argv only. All credentials, impersonation, allowlist path, and scope reach the subprocess through `cmd.Env`, set by the launcher in `mcpconfig.go`. Identity-selecting flags are deny-floored in argv. -- **Tests.** Go race tests per the repo standard; CLI interaction is tested against captured-output fixtures (no live cloud). The wire test fails if `ToolSpecs()` drifts from registration. - ---- - -## PR A — scaffold + harness (#45) - -### Task A1: Package skeleton and server - -**Files:** -- Create: `pkg/mcp/cloud/server.go` -- Create: `pkg/mcp/cloud/provider.go` -- Test: `pkg/mcp/cloud/server_test.go`, `pkg/mcp/cloud/fake_test.go` - -- [ ] **Step 1: Write the failing test** — a `New` with a fake provider returns a server, and `New` with a nil provider errors. - -```go -// server_test.go -func TestNewRequiresProvider(t *testing.T) { - if _, err := New(Options{}); err == nil { - t.Fatal("expected error when Provider is nil") - } - if _, err := New(Options{Provider: &fakeProvider{}}); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} -``` - -- [ ] **Step 2: Define the interface and fake** in `provider.go` and `fake_test.go`. - -```go -// provider.go -type Provider interface { - Name() string // "gcp" | "aws" - Binary() string // resolved absolute path to gcloud/aws - DefaultAllowlist() *CommandAllowlist // embedded default for this provider - DenyFloorAdditions() DenyFloor // provider-specific subcommands/flags - Inventory(ctx context.Context, run RunFunc) (Inventory, error) - Identity(ctx context.Context, run RunFunc) (IdentityStatus, error) -} - -// RunFunc is the harness exec core, injected so providers never exec directly. -type RunFunc func(ctx context.Context, argv []string) (CLIResult, error) - -type Inventory struct { - Scopes []Scope `json:"scopes"` // projects (gcp) / accounts (aws) -} -type Scope struct { - ID, Name string -} -type IdentityStatus struct { - Provider string `json:"provider"` - AssumedIdentity string `json:"assumed_identity"` - Valid bool `json:"valid"` - Hint string `json:"hint,omitempty"` -} -type CLIResult struct { - Stdout string `json:"stdout"` - Truncated bool `json:"truncated"` - ExitCode int `json:"exit_code"` -} -``` - -```go -// fake_test.go -type fakeProvider struct{ identity IdentityStatus } -func (f *fakeProvider) Name() string { return "fake" } -func (f *fakeProvider) Binary() string { return "/bin/true" } -func (f *fakeProvider) DefaultAllowlist() *CommandAllowlist { return &CommandAllowlist{} } -func (f *fakeProvider) DenyFloorAdditions() DenyFloor { return DenyFloor{} } -func (f *fakeProvider) Inventory(context.Context, RunFunc) (Inventory, error) { return Inventory{}, nil } -func (f *fakeProvider) Identity(context.Context, RunFunc) (IdentityStatus, error) { return f.identity, nil } -``` - -- [ ] **Step 3: Implement `server.go`** following the teleport pattern (`Options{Provider}`, `New`, `registerOn`, `Run`, server name `triagent-mcp-cloud`). -- [ ] **Step 4: Run** `go test ./pkg/mcp/cloud/ -run TestNewRequiresProvider -v` → PASS. -- [ ] **Step 5: Commit** `feat(cloud): provider interface and server skeleton (#45)`. - -### Task A2: Command allowlist and deny floor - -**Files:** -- Create: `pkg/mcp/cloud/allowlist.go` -- Test: `pkg/mcp/cloud/allowlist_test.go` - -- [ ] **Step 1: Write failing tests** covering: an override path replaces the embedded default; a command on the deny floor is dropped even if the override lists it; provider deny-floor additions merge in. - -```go -func TestLoadCommandAllowlistDropsDenyFloor(t *testing.T) { - // 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{}) - if err != nil { t.Fatal(err) } - if al.Allows([]string{"secrets","versions","access"}) { - t.Fatal("deny floor must drop secrets access regardless of config") - } - if !al.Allows([]string{"projects","list"}) { - t.Fatal("projects list should be allowed") - } -} -``` - -- [ ] **Step 2: Implement** `Command{Path, Description string, Redact bool}`, `CommandAllowlist{Commands []Command}`, `LoadCommandAllowlist(path string, extra DenyFloor)` mirroring `k8s.LoadAllowlist` (embedded default when path empty, else read file), then filter through the base `denyFloor` plus `extra`. `Allows(path []string)` normalizes and matches. - -```go -// the always-on floor; config can never re-enable these (the Secret pattern) -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"}, - ArgPrefixes: []string{"file://", "fileb://", "@", "http://", "https://"}, -} -``` - -- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -run TestLoadCommandAllowlist -v` → PASS. -- [ ] **Step 4: Commit** `feat(cloud): command allowlist with hardcoded deny floor (#45)`. - -### Task A3: Argv validation - -**Files:** -- Create: `pkg/mcp/cloud/validate.go` -- Test: `pkg/mcp/cloud/validate_test.go` - -- [ ] **Step 1: Write failing tests** — table over: allowed verb passes; un-allowlisted verb rejected; each deny-floor flag rejected; each arg-prefix rejected; `--project` outside scope rejected; shell metacharacter tokens (`;`, `|`, `$(x)`) rejected by allowlist (not interpreted). - -```go -func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { - al := &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}} - scope := ScopeAllowlist{Projects: []string{"prod"}} - cases := []struct{ name string; argv []string; ok bool }{ - {"allowed", []string{"compute","instances","list","--project","prod"}, true}, - {"bad-scope", []string{"compute","instances","list","--project","other"}, false}, - {"impersonate", []string{"compute","instances","list","--impersonate-service-account","x"}, false}, - {"file-prefix", []string{"compute","instances","list","--filter","@/etc/passwd"}, false}, - {"metachar", []string{"compute","instances","list",";","rm","-rf","/"}, false}, - {"not-allowed", []string{"iam","service-accounts","create"}, false}, - } - // assert validateArgv(argv, al, scope) error-ness matches !ok -} -``` - -- [ ] **Step 2: Implement** `validateArgv`: split flags from positionals, normalize the leading subcommand path, `al.Allows`, reject any token matching a deny-floor flag / arg-prefix, validate `--project`/`--account`/region against `ScopeAllowlist`. -- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -run TestValidateArgv -v` → PASS. -- [ ] **Step 4: Commit** `feat(cloud): argv validation against allowlist, deny floor, and scope (#45)`. - -### Task A4: No-shell exec core and truncation - -**Files:** -- Create: `pkg/mcp/cloud/harness.go` -- Test: `pkg/mcp/cloud/harness_test.go`, `pkg/mcp/cloud/harness_security_test.go` - -- [ ] **Step 1: Write failing security tests** — (a) source-level: the package contains no `"sh"`/`"bash"` `-c` exec construction; (b) behavioural: `execCLI` with argv `["-c","echo pwned"]` against `/bin/echo` prints the literal tokens, never spawning a second process; (c) output beyond `limit` sets `Truncated`. - -```go -func TestExecCLINeverUsesShell(t *testing.T) { - src, _ := os.ReadFile("harness.go") - if bytes.Contains(src, []byte(`"-c"`)) || bytes.Contains(src, []byte("sh -c")) { - t.Fatal("harness must never construct a shell command") - } -} -func TestExecCLITruncates(t *testing.T) { - r, err := execCLI(context.Background(), "/bin/echo", []string{strings.Repeat("x", 100)}, nil, 10) - if err != nil { t.Fatal(err) } - if !r.Truncated || len(r.Stdout) > 10 { t.Fatalf("expected truncation, got %+v", r) } -} -``` - -- [ ] **Step 2: Implement** `execCLI` with `exec.CommandContext(ctx, binPath, argv...)`, explicit minimal `cmd.Env`, `cmd.Stdin = nil`, captured stdout with a hard byte cap (`limit`), returning `CLIResult`. No shell, ever. -- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -run TestExecCLI -v -race` → PASS. -- [ ] **Step 4: Commit** `feat(cloud): no-shell argv exec core with output truncation (#45)`. - -### Task A5: Identity probe - -**Files:** -- Create: `pkg/mcp/cloud/probe.go` -- Test: `pkg/mcp/cloud/probe_test.go` - -- [ ] **Step 1: Write failing test** — `Probe` delegates to `Provider.Identity` and returns its `IdentityStatus`; a provider error yields `Valid:false` with the error surfaced as `Hint`. -- [ ] **Step 2: Implement** `Probe(ctx, p Provider) (IdentityStatus, error)` calling `p.Identity` with a `RunFunc` bound to `execCLI` + the provider binary, validating the resolved identity is non-empty. -- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -run TestProbe -v` → PASS. -- [ ] **Step 4: Commit** `feat(cloud): shared identity probe (#45)`. - -### Task A6: Tools and specs - -**Files:** -- Create: `pkg/mcp/cloud/tools_inventory.go`, `tools_status.go`, `tools_cli.go`, `specs.go` -- Test: `pkg/mcp/cloud/tools_test.go`, `pkg/mcp/cloud/tools_wire_test.go` - -- [ ] **Step 1: Write failing tests** (driven by `fakeProvider`): `list_inventory` returns the fake's scopes; `session_status` returns the fake's identity; `list_allowed_commands` returns the loaded allowlist; `run_cli` rejects a deny-floored argv before exec and shapes a `CLIResult` on success; the wire test asserts `ToolSpecs()` names match registered handlers. -- [ ] **Step 2: Implement** the four handlers and `ToolSpecs()` (server `triagent-cloud`, `toolspec.FromStruct` inputs). `run_cli` calls `validateArgv` then `execCLI`; `list_allowed_commands` reads the same `CommandAllowlist`. -- [ ] **Step 3: Run** `go test ./pkg/mcp/cloud/ -v -race` → PASS. -- [ ] **Step 4: Commit** `feat(cloud): list_inventory, session_status, run_cli, list_allowed_commands (#45)`. - -### Task A7: serve.go wiring - -**Files:** -- Modify: `cmd/triagent-mcp/serve.go` (add `case "cloud"`, `--provider` flag, `runCloud`) -- Test: `cmd/triagent-mcp/serve_test.go` - -- [ ] **Step 1: Write failing test** — `--kind=cloud` with no/unknown `--provider` errors with a clear message; a known provider constructs a server. -- [ ] **Step 2: Implement** `runCloud(ctx, f)`: parse `--provider`, construct the gcp/aws impl (imported from the provider subpackages — stubbed to return an error "provider not built yet" until PRs B/C land, so A compiles and tests pass), call `cloud.New(cloud.Options{Provider: impl})`. Add `cloud` to the `--kind` usage strings. -- [ ] **Step 3: Run** `go test ./cmd/triagent-mcp/ -run TestServeCloud -v` → PASS; `make lint` clean. -- [ ] **Step 4: Commit** `feat(cloud): register --kind=cloud --provider in serve.go (#45)`. - ---- - -## PR B — GCP provider (#43) - -Branches from A's merged state (interface + harness available). Implements `cloud.Provider` over `gcloud`. - -### Task B1: Provider skeleton and binary resolution - -**Files:** -- Create: `pkg/mcp/cloud/providers/gcp/provider.go`, `default_commands.json` -- Test: `pkg/mcp/cloud/providers/gcp/provider_test.go` - -- [ ] **Step 1:** Failing test — `New()` resolves the `gcloud` binary (via `exec.LookPath`, overridable for tests) and `Name()` returns `"gcp"`, `DefaultAllowlist()` loads the embedded JSON. -- [ ] **Step 2:** Implement the struct, `//go:embed default_commands.json`, and `DenyFloorAdditions()` (gcp-specific: e.g. `compute ssh`, `compute scp`, `functions call`). -- [ ] **Step 3:** Run `go test ./pkg/mcp/cloud/providers/gcp/ -v` → PASS. -- [ ] **Step 4:** Commit `feat(cloud/gcp): provider skeleton, default allowlist, deny-floor additions (#43)`. - -### Task B2: Identity (whoami over impersonation) - -- [ ] **Step 1:** Failing test over a captured `gcloud auth list --format=json` fixture → `IdentityStatus{AssumedIdentity, Valid}`, reading the active account and the `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT` target. -- [ ] **Step 2:** Implement `Identity(ctx, run)` parsing the fixture shape; `Valid` requires the resolved identity to equal the impersonation target. -- [ ] **Step 3:** Run the test → PASS. -- [ ] **Step 4:** Commit `feat(cloud/gcp): identity probe over impersonation (#43)`. - -### Task B3: Inventory (`gcloud projects list`) - -- [ ] **Step 1:** Failing test over a `gcloud projects list --format=json` fixture → `Inventory{Scopes}`. -- [ ] **Step 2:** Implement `Inventory(ctx, run)` projecting id + name. -- [ ] **Step 3:** Run → PASS. -- [ ] **Step 4:** Commit `feat(cloud/gcp): inventory projection (#43)`. - -### Task B4: Wire the provider into serve.go - -- [ ] **Step 1:** Replace the A7 stub so `--provider=gcp` constructs `gcp.New()`. -- [ ] **Step 2:** Run `go test ./... -race` and `make lint` → PASS. -- [ ] **Step 3:** Commit `feat(cloud): wire gcp provider into serve.go (#43)`. - -## PR C — AWS provider (#46) - -Mirror of PR B over the `aws` CLI. Branches from A's merged state; independent of B. - -### Task C1: Provider skeleton - -- [ ] Binary `aws`; `Name()` `"aws"`; embedded `default_commands.json`; `DenyFloorAdditions()` (aws-specific: e.g. `ec2 get-password-data`, anything that returns credentials material). -- [ ] Commit `feat(cloud/aws): provider skeleton, default allowlist, deny-floor additions (#46)`. - -### Task C2: Identity (`aws sts get-caller-identity`) - -- [ ] Failing test over a `sts get-caller-identity` fixture → `IdentityStatus`; `Valid` requires the resolved ARN to match the pinned role (the `AWS_PROFILE` assume-role target). -- [ ] Commit `feat(cloud/aws): identity probe over assumed role (#46)`. - -### Task C3: Inventory (`aws organizations list-accounts`, fallback `sts get-caller-identity` account) - -- [ ] Failing test over a `list-accounts` fixture → `Inventory{Scopes}`; on `AccessDenied` (no orgs access) fall back to the single caller account. -- [ ] Commit `feat(cloud/aws): inventory projection with single-account fallback (#46)`. - -### Task C4: Wire into serve.go - -- [ ] `--provider=aws` constructs `aws.New()`; `go test ./... -race` + `make lint` → PASS. -- [ ] Commit `feat(cloud): wire aws provider into serve.go (#46)`. - -## PR D — launcher integration (#47) - -Branches from the feature branch **after both B and C have merged** (needs `cloud.Probe`, `IdentityStatus`, the env-const names, and a constructed `cloud.Provider` per source). It depends on the provider packages at compile time: D3/D4 call `cloud.Probe(ctx, cloud.Provider)`, and the only way to obtain a `cloud.Provider` is to construct a concrete gcp/aws value. D therefore introduces a shared factory `pkg/mcp/cloud/providers.New(name) (cloud.Provider, error)` (importing gcp + aws), refactors `cmd/triagent-mcp/serve.go`'s `newCloudProvider` to delegate to it, and uses it in `preflight` and `connections` — mirroring how the launcher already builds `auth.Provider` from `pkg/auth/teleport` / `pkg/auth/kubeconfig`. - -### Task D0: Shared provider factory - -**Files:** -- Create: `pkg/mcp/cloud/providers/registry.go` -- Modify: `cmd/triagent-mcp/serve.go` (delegate `newCloudProvider` to the factory) -- Test: `pkg/mcp/cloud/providers/registry_test.go` - -- [ ] **Step 1:** Failing test — `New("gcp")` returns a non-nil `cloud.Provider` whose `Name()` is `"gcp"`; `New("aws")` likewise; an unknown name errors. -- [ ] **Step 2:** Implement `New(name)` switching to `gcp.New()` / `aws.New()`; refactor `serve.go`'s `newCloudProvider` to call it (removing the per-arm construction the providers added — the factory is now the single construction site). -- [ ] **Step 3:** Run `go test ./pkg/mcp/cloud/providers/ ./cmd/triagent-mcp/ -race` → PASS. -- [ ] **Step 4:** Commit `feat(cloud): shared provider factory; serve.go delegates construction (#47)`. - -### Task D1: Profile `cloud:` block - -**Files:** -- Modify: `internal/profile/profile.go`, `internal/profile/embed.go` (base merge) -- Test: `internal/profile/profile_test.go` - -- [ ] **Step 1:** Failing test — a profile YAML with a `cloud:` block loads into `Profile.Cloud`, and `base:` merge inherits it when the override omits it. -- [ ] **Step 2:** Add `Cloud []CloudSource` with `{Alias, Provider, AssumedIdentity, Scope, CommandAllowlistPath string}`; extend `applyBase`. -- [ ] **Step 3:** Run `go test ./internal/profile/ -race` → PASS. -- [ ] **Step 4:** Commit `feat(profile): cloud source config block (#NEW)`. - -### Task D2: mcpconfig aliasing and env injection - -**Files:** -- Modify: `internal/preflight/mcpconfig.go` -- Test: `internal/preflight/mcpconfig_test.go` - -- [ ] **Step 1:** Failing test — given a `CloudSource`, `writeMCPConfig` emits a `triagent-cloud-` server with `args ["serve","--kind=cloud","--provider=

"]` and env carrying `TRIAGENT_CLOUD_PROVIDER`, `TRIAGENT_CLOUD_ALLOWLIST_PATH`, `TRIAGENT_CLOUD_SCOPE`, and the impersonation env (`CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT` / `AWS_PROFILE`) from the source's `AssumedIdentity`. -- [ ] **Step 2:** Add `MCPAliasCloudPrefix = "triagent-cloud-"`, the env constants, and the cloud loop mirroring the linked-repos loop. -- [ ] **Step 3:** Run `go test ./internal/preflight/ -race` → PASS. -- [ ] **Step 4:** Commit `feat(preflight): wire triagent-cloud- servers with pinned-identity env (#NEW)`. - -### Task D3: Preflight probe and visible degrade - -**Files:** -- Modify: `internal/preflight/preflight.go` -- Test: `internal/preflight/preflight_test.go` - -- [ ] **Step 1:** Failing test — when a cloud source's `cloud.Probe` returns `Valid:false`, the session still starts (no error) but the source is marked unavailable in the `Result`; when `Valid:true` it's available. -- [ ] **Step 2:** Add a `CloudSources []CloudSourceStatus` field to `Result`; run `cloud.Probe` per source after kubeconfig freeze; never return an error for a failed cloud probe (degrade, don't block); attach the `Hint`. -- [ ] **Step 3:** Run `go test ./internal/preflight/ -race` → PASS. -- [ ] **Step 4:** Commit `feat(preflight): cloud identity probe with visible degrade (#NEW)`. - -### Task D4: Connections array and API - -**Files:** -- Modify: `internal/connections/connections.go`, `internal/server/handlers_connections.go` -- Test: `internal/connections/connections_test.go`, `internal/server/handlers_connections_test.go` - -- [ ] **Step 1:** Failing test — `GET /api/connections` includes a `cloud` array of `{provider, assumed_identity, valid, hint}` built from the profile's cloud sources probed at request time; the entries are read-only (no `PUT`/`DELETE` route added for cloud). -- [ ] **Step 2:** Extend the response builder to enumerate profile cloud sources and run `cloud.Probe`; reuse `IdentityStatus` fields directly. -- [ ] **Step 3:** Run `go test ./internal/connections/ ./internal/server/ -race` → PASS. -- [ ] **Step 4:** Commit `feat(connections): read-only cloud identity status in /api/connections (#NEW)`. - -### Task D5: Frontend pill - -**Files:** -- Modify: the connections panel component under `frontend/` -- Test: the panel's vitest spec - -- [ ] **Step 1:** Failing vitest — the panel renders a cloud pill per `cloud[]` entry showing the assumed identity and a checkmark when `valid`, and the reauth `hint` when not; the pill has no edit affordance. -- [ ] **Step 2:** Render the cloud entries alongside Slack/incident.io, read-only. -- [ ] **Step 3:** Run `cd frontend && npm test -- --run` and `npm run typecheck` → PASS. -- [ ] **Step 4:** Commit `feat(web): read-only cloud identity pills in connections panel (#NEW)`. - ---- - -## Self-review - -- **Spec coverage:** package/`--provider`/alias (A1, A7, D2); thin typed tools (A6); `run_cli` + `list_allowed_commands` (A6); no-shell harness + deny floor + scope + truncation (A2–A4); shared probe (A5) across `session_status` (A6), preflight (D3), connections (D4); pinned-identity impersonation env (D2, B2, C2); visible degrade (D3); read-only connections pill (D4–D5); GCP/AWS providers (B, C). Alternatives/non-goals (SDK, OAuth, mutation) are enforced by the CLI-only convention, the deny floor, and the absence of write paths. -- **Placeholder scan:** provider projection internals (B2–B3, C2–C3) are specified as "parse this fixture into this struct" with the fixture and struct named; the exact field-by-field parse is filled during TDD against captured CLI output, which is the correct altitude (inventing `gcloud` JSON keys now would be a guess). No `TBD`/`TODO` remain. -- **Type consistency:** `Provider`, `RunFunc`, `Inventory`/`Scope`, `IdentityStatus`, `CLIResult`, `CommandAllowlist`/`Command`, `DenyFloor`, `ScopeAllowlist`, `cloud.Probe`, `MCPAliasCloudPrefix`, and the `TRIAGENT_CLOUD_*` env names are used consistently across tasks and the contracts table. diff --git a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md b/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md deleted file mode 100644 index 87d492bd..00000000 --- a/docs/superpowers/states/2026-05-30-cloud-context-mcp-state.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -feature: cloud-context-mcp -spec: docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md -plan: docs/superpowers/plans/2026-05-30-cloud-context-mcp.md -tracking_issue: #44 -feature_branch: feature/cloud-context-mcp -feature_worktree: .claude/worktrees/cloud-context-mcp -sub_pr_approval: autonomous -integration_pr: #53 -status: review ---- - -# Read-only cloud-context MCP (GCP and AWS) — orchestration state - -## Phases - -- **Phase 1 (foundational)** — `#45` (scaffold + harness; produces every contract). **Done** — self-merged as #48. -- **Phase 2a (providers, parallel)** — `#43` (GCP provider), `#46` (AWS provider). **Done** — self-merged as #49 / #50. Wave-boundary checkpoint clean: e2e `make test-go` + `make lint` green; coherence sweep found no align-now drift (identical provider layout/constructor/idiom; two differences both deliberate-justified by the gcp-impersonation vs aws-assume-role mechanisms). -- **Phase 2b (parallel)** — `#47` (launcher integration) and the **probe-env remediation**. **Done** — self-merged as #52 / #51. All sub-PRs merged; every sub-issue closed; only epic #44 remains, to close via the integration PR. - -## PRs / worktrees - -| Issue | Branch | Worktree path | PR (→ base) | Status | -| ----- | ------ | ------------- | ----------- | ------ | -| #45 — scaffold + harness | (merged, branch deleted) | (removed) | #48 → feature/cloud-context-mcp | self-merged | -| #43 — GCP provider | (merged, branch deleted) | (removed) | #49 → feature/cloud-context-mcp | self-merged | -| #46 — AWS provider | (merged, branch deleted) | (removed) | #50 → feature/cloud-context-mcp | self-merged | -| #47 — launcher integration | (merged, branch deleted) | (removed) | #52 → feature/cloud-context-mcp | self-merged | -| probe-env remediation (epic #44) | (merged, branch deleted) | (removed) | #51 → feature/cloud-context-mcp | self-merged | - -## Contracts - -| Name | Realization | Realized in | Status | -| ---- | ----------- | ----------- | ------ | -| `cloud-provider-interface` | stub-on-producer-branch (`cloud.Provider` + `fakeProvider` land in #45) | #45 (#48) | locked | -| `cloud-identity-probe` | stub-on-producer-branch (`cloud.Probe` + `IdentityStatus` exported by #45) | #45 (#48) | locked | -| `cloud-serve-cli` | data-only (`serve --kind=cloud --provider=`) | #45 (#48) | locked | -| `cloud-env-contract` | data-only (`TRIAGENT_CLOUD_*` consts in `cloud/env.go`; provider impersonation env via `Provider.EnvPassthrough() []string`) | #45 (#48), provider names in #43/#46 | locked | -| `cloud-provider-factory` | new (discovered): `pkg/mcp/cloud/providers.New(name) (cloud.Provider, error)`, importing gcp+aws; `serve.go` + `preflight` + `connections` consume it | #47 (#52) | locked | - -All four contracts landed with #45 (squash-merged as #48). Phase 2 (#43/#46/#47) is now unblocked. The `Provider` interface gained `EnvPassthrough() []string` during #45 review (see Bubble-up log) — #43/#46 must implement it, returning their CLI's credential/impersonation var names; `PATH`/`HOME` are already in the harness base set. - -## Bubble-up log - -- **2026-05-30 — `probe.go` exec path still inherits the full parent env (parent-package follow-up needed).** `cloud.Probe` builds a `RunFunc` that calls `execCLI(ctx, p.Binary(), argv, nil, …)`. A `nil` `cmd.Env` inherits the **entire** parent environment — the same minimal-env spec violation #45's review fixed in `Server.run` (now uses `subprocessEnv()`), but the probe path was missed. So the identity probe (used by `session_status`, preflight, connections) leaks the launcher's ambient env into the `gcloud`/`aws` subprocess, while `run_cli`/`Inventory` do not — an inconsistency. The probe argv is provider-fixed (agent can't inject), so exfil risk is low, but it's the same spec breach and an asymmetry. **Resolution:** a small parent-package follow-up extracts the minimal-env helper shared by `Server.subprocessEnv` and `Probe`, so the probe forwards only base (PATH/HOME) + `p.EnvPassthrough()`. Surfaced by #46; do it after #43/#46 merge (touches only `probe.go`/`server.go`, no provider conflict). **Note:** providers read their *expected-identity* env via `os.Getenv` in their own process (not the subprocess), so this fix does not change identity-validity logic — only what the whoami subprocess sees. -- **2026-05-30 — per-provider "expected pinned identity" env diverges; #47 must reconcile (coherence).** GCP derives identity validity from `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT` (the impersonation target doubles as the expected identity). AWS added a separate optional `TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN` (strict) and otherwise checks structurally that the caller is an assumed-role. Also, the plan's D2 maps AWS's impersonation env to `AWS_PROFILE` (a profile NAME), but `AssumedIdentity` in the profile is a role ARN — AWS needs BOTH a profile selector (`AWS_PROFILE`) and the expected role ARN, which the current `CloudSource{Alias, Provider, AssumedIdentity, Scope, CommandAllowlistPath}` model doesn't cleanly express. **Resolution (owned by #47 dispatch):** #47 reconciles per-provider env injection in `mcpconfig.go` — gcp: `AssumedIdentity → CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT`; aws: a profile field/Alias → `AWS_PROFILE` plus `AssumedIdentity (role ARN) → TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN`. Decide whether `CloudSource` needs an explicit AWS profile field. Flagged into #47's dispatch context. -- **2026-05-30 — #46 binary fallback (security + coherence, fixing pre-merge).** AWS `New()` fell back to the relative literal `"aws"` when the CLI wasn't on PATH, defeating the spec's absolute-binary pin (poisoned-PATH substitution) and diverging from GCP (which errors + uses a `newWithBinary` test seam). **Resolution:** focused #46 follow-up aligns AWS with GCP (error on missing binary; tests inject via `newWithBinary`). The missing-binary degrade belongs at the launcher (#47 marks the source unavailable), not a relative-path fallback. - -- **2026-05-30 — discovered cross-PR dependency: #47 depends on #43 + #46 at compile time (plan corrected).** The plan claimed PR D (launcher) is "independent of B/C at compile time (references env-var name constants, not provider packages)." That is wrong: D3 (preflight) and D4 (connections) call `cloud.Probe(ctx, cloud.Provider)`, which needs a concrete `cloud.Provider`. A factory can't live in the `cloud` package (gcp/aws import `cloud`, so it would cycle); it must be a neutral package importing both providers — mirroring how the launcher already imports `pkg/auth/teleport` + `pkg/auth/kubeconfig` to build `auth.Provider`. **Resolution:** re-sequenced #47 to Phase 2b (after #43 + #46 self-merge). #47 introduces a shared provider factory `pkg/mcp/cloud/providers` (`New(name) (cloud.Provider, error)`) and refactors `cmd/triagent-mcp/serve.go`'s `newCloudProvider` to delegate to it — a third consumer (serve.go, preflight, connections) justifies the shared helper over copy-paste. **Propagation:** the premature #47 worktree/branch was removed; the plan's PR-breakdown dependency column and PR-D header are corrected; a `cloud-provider-factory` contract row is added. #43/#46 are unaffected (each still wires only its own `serve.go` arm; the factory extraction happens in #47 once serve.go is no longer contended). - -- **2026-05-30 — known `serve.go` resource conflict between #43 and #46 (dispatch-time, pre-logged).** Both providers wire into `cmd/triagent-mcp/serve.go`: each adds an import (`providers/gcp` vs `providers/aws`) to the same import group and replaces its arm of the `newCloudProvider` stub switch (currently a combined `case "gcp", "aws":`). The import-group collision makes a trivial conflict inevitable at whichever provider PR merges **second**. **Resolution (orchestrator owns it):** dispatch both in parallel; each agent makes a minimal, localized edit (only its own import + its own case arm, leaving the other arm's "not built yet" stub untouched). At the second provider merge, resolve by taking the union — both imports, both real case arms. #47 (launcher) touches a disjoint file set and is conflict-free. - -- **2026-05-30 — minimal-env seam missing in the harness (blocks #45 merge).** `cloud.Server.run` (server.go) calls `execCLI(..., argv, nil, ...)`; in Go a nil `cmd.Env` inherits the full parent environment, contradicting the spec's "explicit minimal `cmd.Env`" and `harness.go`'s own doc comment, and leaking the launcher's process env into `gcloud`/`aws`. The env-forwarding seam is owned by the parent package (conventions: subpackages own only CLI specifics), so it must land in #45 before fan-out. Resolution: #45 follow-up adds a provider-contributed env-passthrough (var **names** the CLI needs forwarded) merged with a minimal base set, built once and passed to `execCLI`; `fakeProvider` returns none. **Propagation:** #43/#46 implement the new `Provider` env-passthrough method; #47 unaffected (still injects env onto the `triagent-mcp` process). Interface grows by one method before consumers branch. -- **2026-05-30 — tests must use `testify` (user directive).** All cloud tests convert to `assert`/`require`; CLAUDE.md amended to make this the repo standard (testify is already used in 166 test files). **Propagation:** #43/#46/#47 inherit the rule via CLAUDE.md; their tests use testify from the start. - -## Resume checklist - -For a fresh Claude session resuming this work: - -1. Read this state file in full. -2. Read the plan at the path in the `plan:` frontmatter. -3. Read the spec at the path in the `spec:` frontmatter. -4. Verify each open PR's actual state via `gh pr view `. -5. For each `in-progress` or `draft` row, `cd` to the worktree path and check `git status` + `git log --oneline main..HEAD`. -6. Re-dispatch subagents as needed per `feature-dev-workflow:developing-a-feature` (Phase 2 fans out only after #45 merges). From eddc127688fb4d24b4cd8f0ebc5f6a3c6b2d166a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 17:41:29 +0200 Subject: [PATCH 20/76] docs(cloud): document configuring the GCP and AWS cloud providers (#54) * docs(cloud): add cloud-providers page and register it in docs nav New public docs page covering the read-only GCP/AWS cloud-context MCP: what it gives the agent, the pinned-identity model, per-provider setup (GCP serviceAccountTokenCreator impersonation, AWS assume-role profile), the full cloud: profile block, scope and command allowlists, and visible degrade. Registered the section in both docs/site/lib/sections.ts and frontend/components/DocsView.tsx, placed next to Connections. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(cloud): document the cloud block in profiles and connections Add a "Cloud sources" section and the cloud: block to the profiles page (anatomy YAML plus a prose reference pointing at the cloud-providers page), and a "Cloud (read-only)" subsection to connections explaining that cloud identities are profile-configured, read-only, and validated by the identity probe. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(profile): add commented cloud example to the default profile Operators forking the default see the cloud: block shape (a gcp and an aws source) with every field explained. Kept commented so it does not activate a cloud source on the runnable default. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(web): point the connections cloud note at the SA/assume-role config Enhance the read-only cloud section's note to say identities are pinned in the profile's cloud: block (not entered here), point at the Cloud providers docs for the impersonation / assume-role setup, and name the operator's own re-auth commands. No edit affordance added. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 130 ++++++++++++++++++ docs/content/connections.md | 8 ++ docs/content/profiles.md | 38 +++++ docs/site/lib/sections.ts | 6 + frontend/components/ConnectionsPanel.tsx | 10 +- frontend/components/DocsView.tsx | 7 +- .../profile/profiles/default/profile.yaml | 37 +++++ 7 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 docs/content/cloud-providers.md diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md new file mode 100644 index 00000000..8d023bdb --- /dev/null +++ b/docs/content/cloud-providers.md @@ -0,0 +1,130 @@ +# 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. + +## 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: inventory (which projects/accounts and resources it can see), reachability (VPCs, subnets, firewall rules, security groups, routes), permissions (IAM policies, roles, service accounts), cluster (GKE/EKS networking and node config), logs, and the audit trail. + +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 (the command allowlist, the deny floor, and the read-only IAM grant on the pinned identity) each have to hold for a read to go through, and none of them can be widened by the agent. + +## 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) but has no tool to choose, change, or authenticate one. The deployment grants that 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. + +## 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). + +The profile pins that service account as `assumed_identity`. 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 only when the active `gcloud` account equals the pinned impersonation target. + +## AWS setup + +The operator authenticates normally, for example: + +```sh +aws sso login +``` + +Configure an `~/.aws/config` profile whose `role_arn` is the read-only role, with the operator's base profile as `source_profile`: + +```ini +[profile triage-readonly] +role_arn = arn:aws:iam::123456789012:role/triage-readonly +source_profile = default +region = eu-west-1 +``` + +The profile's `profile:` field selects that assume-role profile via `AWS_PROFILE`, and `assumed_identity` is the expected role ARN. The harness sets `AWS_PROFILE=` on the cloud MCP subprocess, so the AWS CLI assumes the read-only role from the operator's base credentials. As with GCP, the pin lives in environment, so `--profile` stays on the agent's deny floor. + +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 pinned `assumed_identity`. A plain user or root ARN means the assume-role pin did not take effect and base credentials leaked through, so the source degrades. + +## 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 + # Targets any run_cli argv may reference. 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] + # 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 + # For aws, the role ARN the assumed-role caller must resolve to. Validity + # checks the resolved caller against this exact ARN. + assumed_identity: arn:aws:iam::123456789012:role/triage-readonly + # aws-only: the AWS_PROFILE the harness selects for credentials. Its + # role_arn is the read-only role, with the operator's base as + # source_profile. gcp ignores this field. + profile: triage-readonly + scope: + accounts: ["123456789012"] + regions: [eu-west-1] +``` + +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` — the canonical pinned identity shown in the connections panel: a service-account email for GCP, a role ARN for AWS. GCP impersonates it directly. AWS checks it as the expected role ARN for strict validity. +- `profile` — AWS only. The `AWS_PROFILE` selector for the assume-role profile that produces credentials. GCP ignores it. +- `scope` — the target 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 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: + +```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 +``` + +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. + +## 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. + +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. The floor covers 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`), and argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). A too-broad allowlist override cannot punch through it. + +## Visible degrade + +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 cloud source. The connections panel shows the source unavailable with a re-auth hint, and the session starts with the source disabled and visibly marked unavailable. The Kubernetes investigation proceeds without the cloud axis. + +Re-authentication is the operator's own cloud login (`gcloud auth login`, `aws sso login`), not anything entered in Triagent. The probe runs on connections-panel load so the operator can fix a stale credential before starting a session rather than discovering a degraded one mid-investigation. + +## 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..8121d0c1 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 pinned `assumed_identity` and a validity state. Validity comes from an identity probe run on panel load: GCP checks that the active account equals the impersonated 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..68801b84 100644 --- a/docs/content/profiles.md +++ b/docs/content/profiles.md @@ -61,6 +61,19 @@ 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 + scope: + projects: [prod-platform] + 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 +392,35 @@ 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 (a service-account email for GCP, a role ARN for AWS) that the agent can read but never select or escalate, with a `scope` allowlist constraining which projects / accounts / regions any command may reference. + +```yaml +cloud: + - alias: prod-gcp + provider: gcp # "gcp" | "aws" + assumed_identity: triage-readonly@prod.iam.gserviceaccount.com # impersonated SA + scope: + projects: [prod-platform] + regions: [us-central1] + - alias: prod-aws + provider: aws + assumed_identity: arn:aws:iam::123456789012:role/triage-readonly # expected role ARN + profile: triage-readonly # AWS_PROFILE assume-role selector + 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/frontend/components/ConnectionsPanel.tsx b/frontend/components/ConnectionsPanel.tsx index 8431c0b3..29d15fd7 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/ConnectionsPanel.tsx @@ -215,8 +215,14 @@ function CloudConnectionsSection({ cloud }: { cloud: CloudConnection[] }) { cloud

- Read-only cloud identities pinned in the deployment profile. Fix a stale - credential through your own cloud login before starting a session. + Read-only cloud identities pinned in the deployment profile’s{" "} + cloud: block, not + entered here. The service-account impersonation (GCP) or assume-role + profile (AWS) is configured there; see the Cloud providers docs page for + setup. Fix a stale credential through your own cloud login ( + gcloud auth login,{" "} + aws sso login) before + starting a session.

{cloud.map((c, i) => ( diff --git a/frontend/components/DocsView.tsx b/frontend/components/DocsView.tsx index 490a9e61..d36f40c0 100644 --- a/frontend/components/DocsView.tsx +++ b/frontend/components/DocsView.tsx @@ -8,7 +8,7 @@ import remarkGfm from "remark-gfm"; // 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"; +type SectionID = "overview" | "investigations" | "watches" | "mcp" | "playbooks" | "wiki" | "repos" | "connections" | "cloud-providers" | "profiles"; const SECTIONS: { id: SectionID; label: string; subtitle: string }[] = [ { @@ -51,6 +51,11 @@ const SECTIONS: { id: SectionID; label: string; subtitle: string }[] = [ 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/internal/profile/profiles/default/profile.yaml b/internal/profile/profiles/default/profile.yaml index 87714ad7..b6617a0a 100644 --- a/internal/profile/profiles/default/profile.yaml +++ b/internal/profile/profiles/default/profile.yaml @@ -187,6 +187,43 @@ 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 selects `profile` (an AWS_PROFILE whose +# role_arn is the read-only role, source_profile the operator's base) and +# checks the resolved caller against `assumed_identity` (the role ARN). +# `scope` is the projects/accounts/regions allowlist any command may +# reference; an empty axis is unconstrained. `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 configure the +# aws assume-role 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 +# scope: +# projects: [prod-platform] +# regions: [us-central1] +# - alias: prod-aws +# provider: aws +# assumed_identity: arn:aws:iam::123456789012:role/triage-readonly +# profile: triage-readonly +# 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 From a31ff0c2e8988e879098ee65becc73d40a5d35c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 17:45:27 +0200 Subject: [PATCH 21/76] refactor(cloud): thread probe identity explicitly instead of pinning process env (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(cloud): thread expected identity and env through Probe/Identity Provider.Identity and cloud.Probe now take the pinned identity and the subprocess env explicitly instead of reading process-global env. The gcp and aws Identity implementations validate the resolved identity against the threaded expected value, dropping their os.Getenv reads. cloud.Server carries ExpectedIdentity (read once from TRIAGENT_CLOUD_EXPECTED_IDENTITY in the serve subprocess) and builds the probe env via subprocessEnv(). Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(cloud): drop os.Setenv pinning in ProbeSource ProbeSource no longer mutates the launcher's process env (and its serializing mutex) to pin the per-provider expected identity. It now builds the subprocess credential env explicitly — base PATH/HOME plus the provider's declared config-dir passthrough names carried from os.Environ, with the source credential var overlaid — and threads the pinned identity into cloud.Probe. A test pins the no-mutation guarantee. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(preflight): uniform expected-identity env in mcpconfig and serve cloudSourceEnv now sets the uniform TRIAGENT_CLOUD_EXPECTED_IDENTITY for both providers in addition to the per-provider credential env the CLI authenticates with (gcp impersonation target, aws assume-role profile), replacing the aws-only expected-role-ARN env. runCloud reads the uniform env once and threads it into cloud.Options.ExpectedIdentity. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 12 +- internal/preflight/mcpconfig.go | 23 ++-- internal/preflight/mcpconfig_test.go | 11 +- pkg/mcp/cloud/env.go | 6 + pkg/mcp/cloud/fake_test.go | 2 +- pkg/mcp/cloud/probe.go | 18 ++- pkg/mcp/cloud/probe_test.go | 45 ++++---- pkg/mcp/cloud/provider.go | 7 +- pkg/mcp/cloud/providers/aws/identity.go | 23 ++-- pkg/mcp/cloud/providers/aws/identity_test.go | 14 +-- pkg/mcp/cloud/providers/gcp/identity.go | 16 +-- pkg/mcp/cloud/providers/gcp/identity_test.go | 18 +-- pkg/mcp/cloud/providers/probe.go | 110 ++++++++++--------- pkg/mcp/cloud/providers/probe_test.go | 71 ++++++++++++ pkg/mcp/cloud/server.go | 22 ++-- pkg/mcp/cloud/tools_status.go | 2 +- 16 files changed, 248 insertions(+), 152 deletions(-) create mode 100644 pkg/mcp/cloud/providers/probe_test.go diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index 6308f945..7fab3b8a 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -439,8 +439,9 @@ func runProm(ctx context.Context, f serveFlags) error { // 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 and target scope through the subprocess env -// (cloud.EnvAllowlistPath, cloud.EnvScope), never argv. +// the allowlist override path, target scope, and pinned identity through the +// subprocess env (cloud.EnvAllowlistPath, cloud.EnvScope, +// cloud.EnvExpectedIdentity), never argv. 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) @@ -450,9 +451,10 @@ func runCloud(ctx context.Context, f serveFlags) error { return err } srv, err := cloud.New(cloud.Options{ - Provider: provider, - AllowlistPath: os.Getenv(cloud.EnvAllowlistPath), - Scope: parseCloudScope(os.Getenv(cloud.EnvScope)), + Provider: provider, + AllowlistPath: os.Getenv(cloud.EnvAllowlistPath), + Scope: parseCloudScope(os.Getenv(cloud.EnvScope)), + ExpectedIdentity: os.Getenv(cloud.EnvExpectedIdentity), }) if err != nil { return fmt.Errorf("build cloud mcp server: %w", err) diff --git a/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index c01dc5ac..d2686b26 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -181,19 +181,21 @@ func kubeEnv(in mcpConfigInputs) map[string]string { // 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, and the per-provider -// pinned-identity env. +// 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 two clouds pin identity through different env, by mechanism. GCP -// impersonates the assumed identity directly, so a single env -// (CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT) is both the impersonation target -// and the expected identity. AWS selects an assume-role profile (AWS_PROFILE) -// for credentials and checks the role ARN (TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN) -// for strict validity, so it needs both a profile selector and the expected ARN. -// The env-name constants come from the provider packages, never raw literals. +// 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 selects an +// assume-role profile (AWS_PROFILE) whose role_arn is the deployment's read-only +// role. The env-name constants come from the provider packages, never raw +// literals. func cloudSourceEnv(src profile.CloudSource) (map[string]string, error) { env := map[string]string{ - cloud.EnvProvider: src.Provider, + cloud.EnvProvider: src.Provider, + cloud.EnvExpectedIdentity: src.AssumedIdentity, } if src.CommandAllowlistPath != "" { env[cloud.EnvAllowlistPath] = src.CommandAllowlistPath @@ -209,7 +211,6 @@ func cloudSourceEnv(src profile.CloudSource) (map[string]string, error) { env[gcp.EnvImpersonate] = src.AssumedIdentity case "aws": env[aws.EnvProfile] = src.Profile - env[aws.EnvExpectedRoleARN] = src.AssumedIdentity } return env, nil } diff --git a/internal/preflight/mcpconfig_test.go b/internal/preflight/mcpconfig_test.go index 4b0f8334..b7345512 100644 --- a/internal/preflight/mcpconfig_test.go +++ b/internal/preflight/mcpconfig_test.go @@ -422,12 +422,12 @@ func TestWriteMCPConfig_GCPCloudSource_RegistersServerWithImpersonationEnv(t *te require.NotNil(t, env) assert.Equal(t, "gcp", env[cloud.EnvProvider]) assert.Equal(t, "/etc/triagent/gcp-allow.json", env[cloud.EnvAllowlistPath]) - // gcp impersonates the assumed identity directly; that one env is both - // the impersonation target and the expected identity. + // 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) - assert.NotContains(t, env, aws.EnvExpectedRoleARN) rawScope, _ := env[cloud.EnvScope].(string) require.NotEmpty(t, rawScope, "scope must be JSON-encoded into the env") @@ -460,9 +460,10 @@ func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithProfileAndExpectedRole env, _ := srv["env"].(map[string]any) require.NotNil(t, env) assert.Equal(t, "aws", env[cloud.EnvProvider]) - // aws needs BOTH a profile selector and the expected role ARN. + // The pinned identity is uniform across providers. + assert.Equal(t, "arn:aws:iam::123456789012:role/triage-ro", env[cloud.EnvExpectedIdentity]) + // aws selects an assume-role profile as its credential env. assert.Equal(t, "triage-ro", env[aws.EnvProfile]) - assert.Equal(t, "arn:aws:iam::123456789012:role/triage-ro", env[aws.EnvExpectedRoleARN]) // gcp impersonation env must not leak onto an aws source. assert.NotContains(t, env, gcp.EnvImpersonate) } diff --git a/pkg/mcp/cloud/env.go b/pkg/mcp/cloud/env.go index fcb57ae7..d74b7154 100644 --- a/pkg/mcp/cloud/env.go +++ b/pkg/mcp/cloud/env.go @@ -14,4 +14,10 @@ const ( // 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" ) diff --git a/pkg/mcp/cloud/fake_test.go b/pkg/mcp/cloud/fake_test.go index 62593e8f..bdc72071 100644 --- a/pkg/mcp/cloud/fake_test.go +++ b/pkg/mcp/cloud/fake_test.go @@ -46,6 +46,6 @@ func (f *fakeProvider) Inventory(context.Context, RunFunc) (Inventory, error) { return f.inventory, nil } -func (f *fakeProvider) Identity(context.Context, RunFunc) (IdentityStatus, error) { +func (f *fakeProvider) Identity(context.Context, RunFunc, string) (IdentityStatus, error) { return f.identity, f.identityErr } diff --git a/pkg/mcp/cloud/probe.go b/pkg/mcp/cloud/probe.go index fecac471..6a90fd39 100644 --- a/pkg/mcp/cloud/probe.go +++ b/pkg/mcp/cloud/probe.go @@ -1,23 +1,33 @@ package cloud -import "context" +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) (IdentityStatus, error) { - env := minimalEnv(p.EnvPassthrough()) +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) + st, err := p.Identity(ctx, run, expected) if err != nil { return IdentityStatus{ Provider: p.Name(), diff --git a/pkg/mcp/cloud/probe_test.go b/pkg/mcp/cloud/probe_test.go index 8c7df391..a2cf8aea 100644 --- a/pkg/mcp/cloud/probe_test.go +++ b/pkg/mcp/cloud/probe_test.go @@ -3,6 +3,7 @@ package cloud import ( "context" "errors" + "os" "strings" "testing" @@ -29,7 +30,7 @@ func (p *envProbeProvider) Inventory(context.Context, RunFunc) (Inventory, error return Inventory{}, nil } -func (p *envProbeProvider) Identity(ctx context.Context, run RunFunc) (IdentityStatus, error) { +func (p *envProbeProvider) Identity(ctx context.Context, run RunFunc, _ string) (IdentityStatus, error) { res, err := run(ctx, nil) if err != nil { return IdentityStatus{}, err @@ -51,56 +52,62 @@ func TestProbeReturnsProviderIdentity(t *testing.T) { Valid: true, }, } - st, err := Probe(context.Background(), p) + 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) + 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") } -// TestProbeUsesMinimalSubprocessEnv proves the probe path forwards only the -// base passthrough plus the provider's declared names to the whoami subprocess, -// dropping the launcher's ambient secrets. A parent canary must not cross the -// boundary while a declared passthrough var survives. -func TestProbeUsesMinimalSubprocessEnv(t *testing.T) { +// 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") - t.Setenv("CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT", "ro-sa@proj.iam.gserviceaccount.com") - p := &envProbeProvider{ - name: "gcp", - envPassthrough: []string{"CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"}, - } + p := &envProbeProvider{name: "gcp"} - st, err := Probe(context.Background(), p) + 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", - "parent-env secret must not reach the probe subprocess") + "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", - "declared passthrough var must reach the probe subprocess") + "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", "HOME", "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT"}, name, - "only base + declared passthrough names may cross the boundary") + 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) + 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") } diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go index 8abf18b7..0cab1628 100644 --- a/pkg/mcp/cloud/provider.go +++ b/pkg/mcp/cloud/provider.go @@ -36,8 +36,11 @@ type Provider interface { // 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. It execs only through run, never directly. - Identity(ctx context.Context, run RunFunc) (IdentityStatus, error) + // 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) } // RunFunc is the harness exec core, injected into providers so they never exec diff --git a/pkg/mcp/cloud/providers/aws/identity.go b/pkg/mcp/cloud/providers/aws/identity.go index 415017f7..c13b30d6 100644 --- a/pkg/mcp/cloud/providers/aws/identity.go +++ b/pkg/mcp/cloud/providers/aws/identity.go @@ -4,20 +4,11 @@ import ( "context" "encoding/json" "fmt" - "os" "strings" "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) -// EnvExpectedRoleARN optionally pins the IAM role ARN the assumed-role caller -// must resolve to. When set, Identity rejects any caller whose underlying role -// does not match it, the strict check. When unset, Identity falls back to the -// structural check (the caller must be an assumed-role ARN at all, proving the -// AWS_PROFILE assume-role pin took effect rather than the operator's plain base -// identity leaking through). -const EnvExpectedRoleARN = "TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN" - // 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 { @@ -31,12 +22,12 @@ type callerIdentity struct { // 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 TRIAGENT_CLOUD_AWS_EXPECTED_ROLE_ARN set, the -// caller's underlying role must match it exactly. 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. -func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (cloud.IdentityStatus, error) { +// Validity has two modes. With expected set to a role ARN, the caller's +// underlying role must match it exactly. 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. +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 @@ -59,7 +50,7 @@ func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (cloud.Ident } st := cloud.IdentityStatus{Provider: "aws", AssumedIdentity: caller.Arn} - st.Valid, st.Hint = evaluateIdentity(caller.Arn, os.Getenv(EnvExpectedRoleARN)) + st.Valid, st.Hint = evaluateIdentity(caller.Arn, expected) return st, nil } diff --git a/pkg/mcp/cloud/providers/aws/identity_test.go b/pkg/mcp/cloud/providers/aws/identity_test.go index d546bf99..47dbd91d 100644 --- a/pkg/mcp/cloud/providers/aws/identity_test.go +++ b/pkg/mcp/cloud/providers/aws/identity_test.go @@ -28,7 +28,7 @@ func TestIdentityBuildsCallerIdentityArgv(t *testing.T) { p, err := newWithBinary("/usr/bin/aws") require.NoError(t, err) - _, err = p.Identity(context.Background(), f.run) + _, err = p.Identity(context.Background(), f.run, "") require.NoError(t, err) require.Len(t, f.calls, 1) @@ -42,7 +42,7 @@ func TestIdentityValidWhenAssumedRole(t *testing.T) { p, err := newWithBinary("/usr/bin/aws") require.NoError(t, err) - st, err := p.Identity(context.Background(), f.run) + st, err := p.Identity(context.Background(), f.run, "") require.NoError(t, err) assert.Equal(t, "aws", st.Provider) @@ -57,7 +57,7 @@ func TestIdentityInvalidWhenNotAssumedRole(t *testing.T) { p, err := newWithBinary("/usr/bin/aws") require.NoError(t, err) - st, err := p.Identity(context.Background(), f.run) + st, err := p.Identity(context.Background(), f.run, "") require.NoError(t, err) assert.Equal(t, "arn:aws:iam::111122223333:user/operator", st.AssumedIdentity) @@ -66,27 +66,25 @@ func TestIdentityInvalidWhenNotAssumedRole(t *testing.T) { } func TestIdentityMatchesExpectedRoleArnWhenPinned(t *testing.T) { - t.Setenv(EnvExpectedRoleARN, "arn:aws:iam::111122223333:role/triagent-readonly") 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) + 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") } func TestIdentityRejectsMismatchedExpectedRoleArn(t *testing.T) { - t.Setenv(EnvExpectedRoleARN, "arn:aws:iam::111122223333:role/some-other-role") 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) + 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) @@ -99,7 +97,7 @@ func TestIdentityInvalidOnNonZeroExit(t *testing.T) { p, err := newWithBinary("/usr/bin/aws") require.NoError(t, err) - st, err := p.Identity(context.Background(), f.run) + 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/gcp/identity.go b/pkg/mcp/cloud/providers/gcp/identity.go index 8be0460f..b036108b 100644 --- a/pkg/mcp/cloud/providers/gcp/identity.go +++ b/pkg/mcp/cloud/providers/gcp/identity.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) @@ -18,12 +17,9 @@ type authAccount struct { // 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: it reads the active account and reports the session valid only when -// that account equals the pinned impersonation target the launcher set in -// CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT. A degraded auth state surfaces -// through Valid and Hint, never a Go error. -func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (cloud.IdentityStatus, error) { - target := os.Getenv(EnvImpersonate) - +// that account equals expected, the impersonation target the launcher pinned. A +// degraded auth state surfaces through Valid and Hint, never a Go error. +func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc, expected string) (cloud.IdentityStatus, error) { res, err := run(ctx, []string{"auth", "list", "--filter=status:ACTIVE", "--format=json"}) if err != nil { return cloud.IdentityStatus{Provider: "gcp", Valid: false, Hint: err.Error()}, nil @@ -42,15 +38,15 @@ func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc) (cloud.Ident st := cloud.IdentityStatus{Provider: "gcp", AssumedIdentity: active} switch { - case target == "": + case expected == "": st.Valid = false st.Hint = "no impersonation target pinned; set " + EnvImpersonate + " on the cloud MCP subprocess" case active == "": st.Valid = false st.Hint = "no active gcloud account; run: gcloud auth login" - case active != target: + case active != expected: st.Valid = false - st.Hint = fmt.Sprintf("active account %q is not the pinned identity %q", active, target) + st.Hint = fmt.Sprintf("active account %q is not the pinned identity %q", active, expected) default: st.Valid = true } diff --git a/pkg/mcp/cloud/providers/gcp/identity_test.go b/pkg/mcp/cloud/providers/gcp/identity_test.go index ef553ac8..04ba498a 100644 --- a/pkg/mcp/cloud/providers/gcp/identity_test.go +++ b/pkg/mcp/cloud/providers/gcp/identity_test.go @@ -30,11 +30,10 @@ func runReturning(out string) cloud.RunFunc { } func TestIdentityResolvesActiveAccountAsTarget(t *testing.T) { - t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) - st, err := p.Identity(context.Background(), runReturning(authListJSON)) + st, err := p.Identity(context.Background(), runReturning(authListJSON), "ro-sa@proj.iam.gserviceaccount.com") require.NoError(t, err) assert.Equal(t, "gcp", st.Provider) assert.Equal(t, "ro-sa@proj.iam.gserviceaccount.com", st.AssumedIdentity) @@ -42,12 +41,11 @@ func TestIdentityResolvesActiveAccountAsTarget(t *testing.T) { } func TestIdentityInvalidWhenActiveAccountIsNotTheTarget(t *testing.T) { - t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) mismatch := `[{"account": "operator@example.com", "status": "ACTIVE"}]` - st, err := p.Identity(context.Background(), runReturning(mismatch)) + st, err := p.Identity(context.Background(), runReturning(mismatch), "ro-sa@proj.iam.gserviceaccount.com") require.NoError(t, err) assert.Equal(t, "operator@example.com", st.AssumedIdentity) assert.False(t, st.Valid, "active account differs from the impersonation target") @@ -55,11 +53,10 @@ func TestIdentityInvalidWhenActiveAccountIsNotTheTarget(t *testing.T) { } func TestIdentityInvalidWhenNoActiveAccount(t *testing.T) { - t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) - st, err := p.Identity(context.Background(), runReturning(`[]`)) + st, err := p.Identity(context.Background(), runReturning(`[]`), "ro-sa@proj.iam.gserviceaccount.com") require.NoError(t, err) assert.Empty(t, st.AssumedIdentity) assert.False(t, st.Valid) @@ -67,32 +64,29 @@ func TestIdentityInvalidWhenNoActiveAccount(t *testing.T) { } func TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { - t.Setenv(EnvImpersonate, "") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) - st, err := p.Identity(context.Background(), runReturning(authListJSON)) + st, err := p.Identity(context.Background(), runReturning(authListJSON), "") require.NoError(t, err) assert.False(t, st.Valid, "no pinned target means the session is not validly pinned") assert.NotEmpty(t, st.Hint) } func TestIdentitySurfacesRunErrorAsHint(t *testing.T) { - t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") 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("gcloud not authenticated") }) - st, err := p.Identity(context.Background(), failing) + st, err := p.Identity(context.Background(), failing, "ro-sa@proj.iam.gserviceaccount.com") 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") } func TestIdentityCallsAuthListWithJSONFormat(t *testing.T) { - t.Setenv(EnvImpersonate, "ro-sa@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) @@ -101,7 +95,7 @@ func TestIdentityCallsAuthListWithJSONFormat(t *testing.T) { gotArgv = argv return cloud.CLIResult{Stdout: authListJSON}, nil }) - _, err = p.Identity(context.Background(), capturing) + _, err = p.Identity(context.Background(), capturing, "ro-sa@proj.iam.gserviceaccount.com") require.NoError(t, err) assert.Equal(t, []string{"auth", "list", "--filter=status:ACTIVE", "--format=json"}, gotArgv) } diff --git a/pkg/mcp/cloud/providers/probe.go b/pkg/mcp/cloud/providers/probe.go index 4e660b0d..98d8827c 100644 --- a/pkg/mcp/cloud/providers/probe.go +++ b/pkg/mcp/cloud/providers/probe.go @@ -3,13 +3,19 @@ package providers import ( "context" "os" - "sync" + "strings" "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" ) +// 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 (aws only) the assume-role profile. // It carries exactly what ProbeSource needs without coupling this package to @@ -20,68 +26,72 @@ type Source struct { Profile string // aws AWS_PROFILE selector; ignored by gcp } -// probeEnvMu serializes the env pinning ProbeSource does. The whoami probe runs -// in the launcher process, where the provider reads its expected-identity env -// via os.Getenv; ProbeSource pins that env around the probe and restores it, so -// concurrent probes for different sources cannot read each other's pin. -var probeEnvMu sync.Mutex - // ProbeSource constructs the source's provider and runs the read-only identity -// probe, pinning the per-provider expected-identity env for the duration so the -// probe validates against this source's pinned identity. 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. +// probe, threading the source's pinned identity and the subprocess credential +// env explicitly so concurrent probes for different sources never share state. +// 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 { p, err := New(src.Provider) if err != nil { return cloud.IdentityStatus{Provider: src.Provider, Valid: false, Hint: err.Error()} } - probeEnvMu.Lock() - defer probeEnvMu.Unlock() - defer pinIdentityEnv(src)() - - st, _ := cloud.Probe(ctx, p) + st, _ := cloud.Probe(ctx, p, src.AssumedIdentity, sourceEnvFor(p, src)) return st } -// pinIdentityEnv sets the per-provider expected-identity env for src and returns -// a restore func. gcp impersonates the assumed identity directly; aws selects an -// assume-role profile and checks the expected role ARN. The names come from the -// provider packages, never raw literals. -func pinIdentityEnv(src Source) func() { - switch src.Provider { - case "gcp": - return setEnv(map[string]string{gcp.EnvImpersonate: src.AssumedIdentity}) - case "aws": - return setEnv(map[string]string{ - aws.EnvProfile: src.Profile, - aws.EnvExpectedRoleARN: src.AssumedIdentity, - }) - default: - return func() {} - } +// 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 } -// setEnv sets each name to its value and returns a func restoring the prior -// values (including unset for names that were absent). -func setEnv(vals map[string]string) func() { - prior := make(map[string]*string, len(vals)) - for name, val := range vals { - if old, ok := os.LookupEnv(name); ok { - prior[name] = &old - } else { - prior[name] = nil - } - _ = os.Setenv(name, val) +// 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 } - return func() { - for name, old := range prior { - if old == nil { - _ = os.Unsetenv(name) - } else { - _ = os.Setenv(name, *old) - } + + 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. 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": + return map[string]string{aws.EnvProfile: src.Profile} + default: + return nil } } diff --git a/pkg/mcp/cloud/providers/probe_test.go b/pkg/mcp/cloud/providers/probe_test.go new file mode 100644 index 00000000..08c0498c --- /dev/null +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -0,0 +1,71 @@ +package providers + +import ( + "context" + "os" + "testing" + + "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" +) + +// 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") + 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", AssumedIdentity: "arn:aws:iam::111122223333:role/triage-ro", Profile: "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) +} + +// 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/server.go b/pkg/mcp/cloud/server.go index 3e2d8a66..d03ca44e 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -29,14 +29,19 @@ type Options struct { // 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 + impl *mcp.Server + provider Provider + allowlist *CommandAllowlist + scope ScopeAllowlist + expectedIdentity string } // New constructs a cloud-context MCP server. Provider is required. The command @@ -56,10 +61,11 @@ func New(opts Options) (*Server, error) { Version: "0.1.0", }, nil) s := &Server{ - impl: impl, - provider: opts.Provider, - allowlist: allow, - scope: opts.Scope, + impl: impl, + provider: opts.Provider, + allowlist: allow, + scope: opts.Scope, + expectedIdentity: opts.ExpectedIdentity, } s.registerOn(impl) return s, nil diff --git a/pkg/mcp/cloud/tools_status.go b/pkg/mcp/cloud/tools_status.go index 08ead5e0..02ecaca5 100644 --- a/pkg/mcp/cloud/tools_status.go +++ b/pkg/mcp/cloud/tools_status.go @@ -20,7 +20,7 @@ type SessionStatusOutput = IdentityStatus // 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) + st, err := Probe(ctx, s.provider, s.expectedIdentity, s.subprocessEnv()) if err != nil { return errorResult(err.Error()), SessionStatusOutput{}, nil } From 25986bd0abe9efe21dafabb9843b1596c454bacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 18:25:53 +0200 Subject: [PATCH 22/76] fix(cloud): enforce cloud-source degrade and carry the source alias (#57) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 22 +++++++++------ frontend/components/ConnectionsPanel.test.tsx | 10 +++++-- frontend/components/ConnectionsPanel.tsx | 13 +++++---- frontend/lib/api.ts | 8 ++++-- internal/preflight/preflight.go | 28 +++++++++++++++++-- internal/preflight/preflight_test.go | 21 ++++++++++++++ internal/server/handlers_connections.go | 11 +++++--- internal/server/handlers_connections_test.go | 3 ++ 8 files changed, 91 insertions(+), 25 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 8d023bdb..4bc6dbff 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 66e0c116..1789ee01 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 29d15fd7..a2d40b03 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 56e12fb2..092a7158 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 9610cf72..b9847834 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 17eb8163..b088ba1b 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 61300c87..41eb43fb 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 23f41d17..e6243f57 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 435219e4a960e3deff8c28b79eae4ea51e8985ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 18:28:52 +0200 Subject: [PATCH 23/76] fix(cloud): address review findings in the harness, allowlist, and providers (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cloud): capture stderr in CLIResult execCLI used cmd.Output(), discarding the child's stderr where gcloud and aws write their error context. A non-zero run_cli returned an empty stdout and no explanation. Capture stderr into a capped buffer, surface it as CLIResult.Stderr (json "stderr,omitempty"), and truncate it at the same byte limit as stdout. The no-shell, minimal-env, closed-stdin guarantees and the "non-zero exit is a normal result" contract are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud): match allowlisted verb chains as a prefix and reject metacharacter tokens Allows exact-matched the positional subcommand path, so an allowlisted "compute instances describe" rejected "compute instances describe my-vm" — the resource operand made the path unequal, leaving most describe/get commands advertised but unusable. Match the allowlisted path as a token-wise prefix of argv's leading positionals so trailing resource operands ride through. There is no shell, so a trailing token is an inert argument. As defense in depth, validateArgv now rejects any argv token that is or contains a shell-control sequence (`;`, `|`, `&`, backtick, `$(`, `>`, `<`, newline). A metacharacter token like ["...","list",";","rm"] is refused by this check rather than by the allowlist; a literal resource name or a key=value filter passes. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud/gcp): validate impersonation instead of comparing the base account `gcloud auth list` reports the operator's base active account, not the SA selected by CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT, so the old active==expected check marked correctly-configured impersonation invalid. Validity now means "impersonation is pinned to the expected SA and the pin works": read the in-process impersonation env, confirm it equals the expected target, then run a minimal impersonated read (gcloud auth print-access-token) to prove the grant is active. AssumedIdentity is the SA on success; failures degrade through Valid/Hint with the captured stderr. NOTE: needs verification against a live gcloud before relying on the exact print-access-token shape (flagged in a code comment). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud/gcp): check exit code before parsing projects list execCLI returns a non-zero exit as CLIResult{ExitCode:n} with err==nil, so a failed `gcloud projects list` was JSON-parsed and surfaced as a misleading parse error. Check ExitCode before unmarshalling and return the exit code plus captured stderr, mirroring the AWS provider. (The gcp identity probe added in the prior commit already checks ExitCode.) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud/aws): fall back to caller account only on Organizations-unavailable Inventory fell back to the single-account projection on ANY non-zero exit or transport error, masking throttling, network faults, and other real failures. Now that stderr is captured, fall back only when the stderr names an Organizations-unavailable condition (AccessDenied, "not a member of an organization", or AWSOrganizationsNotInUseException). Any other non-zero exit returns the exit code plus stderr; a transport error is surfaced rather than silently degrading. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud/aws): parse assumed-role ARNs across partitions and IAM paths assumedRoleARN hardcoded arn:aws:sts:: and Cut at the first slash, so GovCloud (arn:aws-us-gov:) / China (arn:aws-cn:) ARNs and roles carrying an IAM path (assumed-role/path/to/Role/session) misparsed. Accept any partition, and split so the role path-and-name is everything between assumed-role/ and the final /, rebuilding the IAM role ARN under the same partition. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(cloud): correct ScopeAllowlist account-scope claim The doc comment said the agent "cannot pivot to an un-allowlisted project, account, or region", but allowedFor only maps --project and --region/--zone — Accounts is not argv-enforced. State it accurately: project and region/zone are enforced against argv; account reach is constrained by the pinned identity/role and the deny-floored --account / --profile flags, not validated here. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud): fail closed on a malformed cloud scope parseCloudScope swallowed a malformed TRIAGENT_CLOUD_SCOPE and returned an empty (unconstrained) ScopeAllowlist, failing OPEN and silently widening run_cli. It now returns an error, and runCloud parses the scope before resolving the provider and aborts startup on a malformed value, so a misconfigured scope can never silently drop the deployment's restrictions. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 20 ++-- cmd/triagent-mcp/serve_cloud_test.go | 31 ++++++ pkg/mcp/cloud/allowlist.go | 27 ++--- pkg/mcp/cloud/allowlist_test.go | 28 ++++- pkg/mcp/cloud/harness.go | 20 +++- pkg/mcp/cloud/harness_test.go | 24 +++++ pkg/mcp/cloud/provider.go | 1 + pkg/mcp/cloud/providers/aws/identity.go | 49 +++++++-- pkg/mcp/cloud/providers/aws/identity_test.go | 54 ++++++++++ pkg/mcp/cloud/providers/aws/inventory.go | 42 ++++++-- pkg/mcp/cloud/providers/aws/inventory_test.go | 43 ++++++-- pkg/mcp/cloud/providers/gcp/identity.go | 87 ++++++++------- pkg/mcp/cloud/providers/gcp/identity_test.go | 100 ++++++++---------- pkg/mcp/cloud/providers/gcp/inventory.go | 3 + pkg/mcp/cloud/providers/gcp/inventory_test.go | 19 ++++ pkg/mcp/cloud/validate.go | 36 +++++-- pkg/mcp/cloud/validate_test.go | 35 ++++++ 17 files changed, 463 insertions(+), 156 deletions(-) diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index 7fab3b8a..ced36e89 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -446,6 +446,10 @@ 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) + } provider, err := providers.New(f.cloudProvider) if err != nil { return err @@ -453,7 +457,7 @@ func runCloud(ctx context.Context, f serveFlags) error { srv, err := cloud.New(cloud.Options{ Provider: provider, AllowlistPath: os.Getenv(cloud.EnvAllowlistPath), - Scope: parseCloudScope(os.Getenv(cloud.EnvScope)), + Scope: scope, ExpectedIdentity: os.Getenv(cloud.EnvExpectedIdentity), }) if err != nil { @@ -465,18 +469,18 @@ func runCloud(ctx context.Context, f serveFlags) error { // 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 logged and treated the same, -// so a bad profile entry never silently widens scope. -func parseCloudScope(raw string) cloud.ScopeAllowlist { +// 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 + return scope, nil } if err := json.Unmarshal([]byte(raw), &scope); err != nil { - log.Warn("mcp serve --kind=cloud: ignoring malformed scope", "error", err) - return cloud.ScopeAllowlist{} + return cloud.ScopeAllowlist{}, fmt.Errorf("malformed cloud scope in $%s: %w", cloud.EnvScope, err) } - return scope + return scope, nil } func runGit(ctx context.Context, f serveFlags) error { diff --git a/cmd/triagent-mcp/serve_cloud_test.go b/cmd/triagent-mcp/serve_cloud_test.go index 32504a07..6358782a 100644 --- a/cmd/triagent-mcp/serve_cloud_test.go +++ b/cmd/triagent-mcp/serve_cloud_test.go @@ -34,3 +34,34 @@ func TestServeCmd_KnowsCloudKind(t *testing.T) { 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.Projects) + assert.Empty(t, scope.Regions) + assert.Empty(t, scope.Accounts) +} + +func TestParseCloudScope_ValidJSON(t *testing.T) { + t.Parallel() + scope, err := parseCloudScope(`{"projects":["prod"],"regions":["us-central1"]}`) + require.NoError(t, err) + assert.Equal(t, []string{"prod"}, scope.Projects) + assert.Equal(t, []string{"us-central1"}, scope.Regions) +} + +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") +} diff --git a/pkg/mcp/cloud/allowlist.go b/pkg/mcp/cloud/allowlist.go index 409a90b7..7379a35a 100644 --- a/pkg/mcp/cloud/allowlist.go +++ b/pkg/mcp/cloud/allowlist.go @@ -109,15 +109,17 @@ func filterAllowlist(list *CommandAllowlist, extra DenyFloor) *CommandAllowlist return out } -// Allows reports whether argv's positional subcommand path exactly equals an -// allowlisted command path. Flag tokens and their values do not participate; -// only the leading positionals do. The match is exact rather than prefix so a -// surplus positional — a shell metacharacter token, an extra argument — never -// rides through on the back of an allowed prefix. +// 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 pathEqual(path, normalizePath(c.Path)) { + if pathHasPrefix(path, normalizePath(c.Path)) { return true } } @@ -169,16 +171,3 @@ func pathHasPrefix(path, prefix []string) bool { } return true } - -// pathEqual reports whether two token paths are identical. -func pathEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} diff --git a/pkg/mcp/cloud/allowlist_test.go b/pkg/mcp/cloud/allowlist_test.go index 1dbd5129..295cc04c 100644 --- a/pkg/mcp/cloud/allowlist_test.go +++ b/pkg/mcp/cloud/allowlist_test.go @@ -48,7 +48,7 @@ func TestLoadCommandAllowlistMergesProviderDenyFloorAdditions(t *testing.T) { "compute instances list should remain allowed") } -func TestAllowsMatchesLongestPathPrefix(t *testing.T) { +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"}), @@ -56,3 +56,29 @@ func TestAllowsMatchesLongestPathPrefix(t *testing.T) { 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/harness.go b/pkg/mcp/cloud/harness.go index c185be78..8d11dc06 100644 --- a/pkg/mcp/cloud/harness.go +++ b/pkg/mcp/cloud/harness.go @@ -1,6 +1,7 @@ package cloud import ( + "bytes" "context" "errors" "os/exec" @@ -16,20 +17,35 @@ const defaultOutputLimit = 64 * 1024 // so a poisoned PATH cannot redirect the binary and ambient secrets do not // leak), closed stdin (no interactive prompt), and stdout capped at limit. 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. +// 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 - out, err := cmd.Output() + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + res := CLIResult{} + out := stdout.Bytes() if len(out) > limit { out = out[:limit] res.Truncated = true } res.Stdout = string(out) + errOut := stderr.Bytes() + if len(errOut) > limit { + errOut = errOut[:limit] + res.Truncated = true + } + res.Stderr = string(errOut) + if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { diff --git a/pkg/mcp/cloud/harness_test.go b/pkg/mcp/cloud/harness_test.go index 9730e346..0d4861f1 100644 --- a/pkg/mcp/cloud/harness_test.go +++ b/pkg/mcp/cloud/harness_test.go @@ -17,3 +17,27 @@ func TestExecCLIExitCode(t *testing.T) { 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") +} diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go index 0cab1628..111ebad1 100644 --- a/pkg/mcp/cloud/provider.go +++ b/pkg/mcp/cloud/provider.go @@ -73,6 +73,7 @@ type IdentityStatus struct { // is never surfaced; the harness caps output and reports 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/identity.go b/pkg/mcp/cloud/providers/aws/identity.go index c13b30d6..9ba94cc4 100644 --- a/pkg/mcp/cloud/providers/aws/identity.go +++ b/pkg/mcp/cloud/providers/aws/identity.go @@ -70,23 +70,56 @@ func evaluateIdentity(arn, expectedRoleARN string) (bool, string) { // 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:aws:sts:::assumed-role//; the IAM role -// it stands for is arn:aws:iam:::role/. +// 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 prefix = "arn:aws:sts::" + const stsInfix = ":sts::" const marker = ":assumed-role/" - if !strings.HasPrefix(arn, prefix) { + 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(prefix):idx] + account := arn[len("arn:"+partition+stsInfix):idx] rest := arn[idx+len(marker):] - roleName, _, found := strings.Cut(rest, "/") - if !found || roleName == "" || account == "" { + // 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:aws:iam::%s:role/%s", account, roleName), true + 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 index 47dbd91d..1eff919b 100644 --- a/pkg/mcp/cloud/providers/aws/identity_test.go +++ b/pkg/mcp/cloud/providers/aws/identity_test.go @@ -90,6 +90,60 @@ func TestIdentityRejectsMismatchedExpectedRoleArn(t *testing.T) { 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: ""}, diff --git a/pkg/mcp/cloud/providers/aws/inventory.go b/pkg/mcp/cloud/providers/aws/inventory.go index 6f761644..2b59d6f3 100644 --- a/pkg/mcp/cloud/providers/aws/inventory.go +++ b/pkg/mcp/cloud/providers/aws/inventory.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) @@ -21,15 +22,23 @@ type organizationsAccount struct { } // Inventory projects the AWS accounts the pinned identity can read. The primary -// source is `aws organizations list-accounts`; when the identity lacks -// Organizations access (AccessDenied, surfaced as a non-zero exit or a transport -// error) it falls back to the single account the caller is in, derived from `aws -// sts get-caller-identity`. Both commands are allowlisted so the projection works -// under the validated run core. +// source is `aws organizations list-accounts`; only when that fails with an +// Organizations-unavailable condition (AccessDenied or the account not being a +// member of an organization) does it fall back to the single account the caller +// is in, derived from `aws sts get-caller-identity`. Any other failure — a +// transport error, throttling, a network fault — is surfaced rather than masked +// behind the single-account fallback. Both commands are allowlisted so the +// projection works under the validated run core. func (p *Provider) Inventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { res, err := run(ctx, []string{"organizations", "list-accounts", "--output", "json"}) - if err != nil || res.ExitCode != 0 { - return p.callerAccountInventory(ctx, run) + if err != nil { + return cloud.Inventory{}, fmt.Errorf("aws organizations list-accounts: %w", err) + } + if res.ExitCode != 0 { + if isOrgsUnavailable(res.Stderr) { + return p.callerAccountInventory(ctx, run) + } + return cloud.Inventory{}, fmt.Errorf("aws organizations list-accounts failed (exit %d): %s", res.ExitCode, res.Stderr) } var parsed listAccountsResult @@ -47,6 +56,25 @@ func (p *Provider) Inventory(ctx context.Context, run cloud.RunFunc) (cloud.Inve return cloud.Inventory{Scopes: scopes}, nil } +// isOrgsUnavailable reports whether a non-zero `organizations list-accounts` +// stderr is the benign "Organizations is not available to this identity" +// condition — the only failure the single-account fallback is correct for. AWS +// returns AccessDenied when the role lacks Organizations permissions and +// AWSOrganizationsNotInUseException (or "not a member of an organization") when +// the account is standalone. +func isOrgsUnavailable(stderr string) bool { + for _, marker := range []string{ + "AccessDenied", + "not a member of an organization", + "AWSOrganizationsNotInUseException", + } { + if strings.Contains(stderr, marker) { + return true + } + } + return false +} + // callerAccountInventory derives the single-account inventory from the caller // identity, the fallback when Organizations access is denied. func (p *Provider) callerAccountInventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { diff --git a/pkg/mcp/cloud/providers/aws/inventory_test.go b/pkg/mcp/cloud/providers/aws/inventory_test.go index 56b3c751..63053b0b 100644 --- a/pkg/mcp/cloud/providers/aws/inventory_test.go +++ b/pkg/mcp/cloud/providers/aws/inventory_test.go @@ -38,10 +38,8 @@ func TestInventoryProjectsActiveAccounts(t *testing.T) { func TestInventoryFallsBackToCallerAccountOnAccessDenied(t *testing.T) { f := &fakeRun{ results: map[string]cloud.CLIResult{ - "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, - }, - errs: map[string]error{ - "organizations list-accounts": errAccessDenied, + "organizations list-accounts": {ExitCode: 254, Stderr: "An error occurred (AccessDeniedException) when calling the ListAccounts operation: ..."}, + "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, }, } p, err := newWithBinary("/usr/bin/aws") @@ -54,10 +52,10 @@ func TestInventoryFallsBackToCallerAccountOnAccessDenied(t *testing.T) { assert.Equal(t, "111122223333", inv.Scopes[0].ID) } -func TestInventoryFallsBackOnAccessDeniedExitCode(t *testing.T) { +func TestInventoryFallsBackWhenOrgsNotInUse(t *testing.T) { f := &fakeRun{ results: map[string]cloud.CLIResult{ - "organizations list-accounts": {ExitCode: 254, Stdout: "An error occurred (AccessDeniedException) when calling the ListAccounts operation: ..."}, + "organizations list-accounts": {ExitCode: 254, Stderr: "An error occurred (AWSOrganizationsNotInUseException) when calling the ListAccounts operation"}, "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, }, } @@ -69,3 +67,36 @@ func TestInventoryFallsBackOnAccessDeniedExitCode(t *testing.T) { require.Len(t, inv.Scopes, 1) assert.Equal(t, "111122223333", inv.Scopes[0].ID) } + +func TestInventorySurfacesNonAccessDeniedFailure(t *testing.T) { + f := &fakeRun{ + results: map[string]cloud.CLIResult{ + "organizations list-accounts": {ExitCode: 254, Stderr: "An error occurred (ThrottlingException) when calling the ListAccounts operation: Rate exceeded"}, + "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, + }, + } + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + _, err = p.Inventory(context.Background(), f.run) + require.Error(t, err, "a throttling failure must surface, not silently fall back") + assert.Contains(t, err.Error(), "ThrottlingException", "the error surfaces the captured stderr") + // The fallback caller-identity call must not have run. + for _, c := range f.calls { + assert.NotEqual(t, []string{"sts", "get-caller-identity", "--output", "json"}, c, + "a non-AccessDenied failure must not trigger the single-account fallback") + } +} + +func TestInventorySurfacesTransportError(t *testing.T) { + f := &fakeRun{ + errs: map[string]error{ + "organizations list-accounts": errAccessDenied, + }, + } + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + + _, err = p.Inventory(context.Background(), f.run) + require.Error(t, err, "a process that could not be run is a transport failure, surfaced not masked") +} diff --git a/pkg/mcp/cloud/providers/gcp/identity.go b/pkg/mcp/cloud/providers/gcp/identity.go index b036108b..75d9466d 100644 --- a/pkg/mcp/cloud/providers/gcp/identity.go +++ b/pkg/mcp/cloud/providers/gcp/identity.go @@ -2,65 +2,62 @@ package gcp import ( "context" - "encoding/json" - "fmt" + "os" "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) -// authAccount is one entry of `gcloud auth list --format=json`. -type authAccount struct { - Account string `json:"account"` - Status string `json:"status"` -} - // 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: it reads the active account and reports the session valid only when -// that account equals expected, the impersonation target the launcher pinned. A +// 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 reads the in-process impersonation env (the launcher sets it on the +// subprocess; the agent cannot reach it), confirms it is pinned to the expected +// target, then runs a minimal impersonated read to prove the pin took effect. 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) { - res, err := run(ctx, []string{"auth", "list", "--filter=status:ACTIVE", "--format=json"}) - if err != nil { - return cloud.IdentityStatus{Provider: "gcp", Valid: false, Hint: err.Error()}, nil - } + st := cloud.IdentityStatus{Provider: "gcp"} - var accounts []authAccount - if err := json.Unmarshal([]byte(res.Stdout), &accounts); err != nil { - return cloud.IdentityStatus{ - Provider: "gcp", - Valid: false, - Hint: fmt.Sprintf("parse gcloud auth list output: %v", err), - }, nil - } - - active := activeAccount(accounts) - st := cloud.IdentityStatus{Provider: "gcp", AssumedIdentity: active} - - switch { - case expected == "": + if expected == "" { st.Valid = false st.Hint = "no impersonation target pinned; set " + EnvImpersonate + " on the cloud MCP subprocess" - case active == "": - st.Valid = false - st.Hint = "no active gcloud account; run: gcloud auth login" - case active != expected: + return st, nil + } + + pinned := os.Getenv(EnvImpersonate) + if pinned != expected { st.Valid = false - st.Hint = fmt.Sprintf("active account %q is not the pinned identity %q", active, expected) - default: - st.Valid = true + st.Hint = EnvImpersonate + " is not pinned to the expected identity " + expected + + "; the launcher must set it on the cloud MCP subprocess" + return st, nil } - return st, nil -} -// activeAccount returns the first account marked ACTIVE, or "" when none is. The -// --filter=status:ACTIVE argv already narrows this server-side; the status check -// is the belt to that braces. -func activeAccount(accounts []authAccount) string { - for _, a := range accounts { - if a.Status == "ACTIVE" { - return a.Account + // 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 } - return "" + + 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 index 04ba498a..063d583c 100644 --- a/pkg/mcp/cloud/providers/gcp/identity_test.go +++ b/pkg/mcp/cloud/providers/gcp/identity_test.go @@ -10,92 +10,86 @@ import ( "github.com/stretchr/testify/require" ) -// authListJSON is captured `gcloud auth list --format=json` output: an array of -// accounts, exactly one with status ACTIVE. -const authListJSON = `[ - { - "account": "ro-sa@proj.iam.gserviceaccount.com", - "status": "ACTIVE" - }, - { - "account": "operator@example.com", - "status": "" - } -]` +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 runReturning(out string) cloud.RunFunc { - return func(context.Context, []string) (cloud.CLIResult, error) { - return cloud.CLIResult{Stdout: out}, nil - } +func (f *fakeRun) run(_ context.Context, argv []string) (cloud.CLIResult, error) { + f.calls = append(f.calls, argv) + return f.result, f.err } -func TestIdentityResolvesActiveAccountAsTarget(t *testing.T) { +func TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { + t.Setenv(EnvImpersonate, targetSA) p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) - st, err := p.Identity(context.Background(), runReturning(authListJSON), "ro-sa@proj.iam.gserviceaccount.com") + f := &fakeRun{result: cloud.CLIResult{Stdout: `"token"`}} + st, err := p.Identity(context.Background(), f.run, "") require.NoError(t, err) - assert.Equal(t, "gcp", st.Provider) - assert.Equal(t, "ro-sa@proj.iam.gserviceaccount.com", st.AssumedIdentity) - assert.True(t, st.Valid, "active account equals the impersonation target") + 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 TestIdentityInvalidWhenActiveAccountIsNotTheTarget(t *testing.T) { +func TestIdentityInvalidWhenImpersonationEnvNotPinnedToExpected(t *testing.T) { + t.Setenv(EnvImpersonate, "someone-else@proj.iam.gserviceaccount.com") p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) - mismatch := `[{"account": "operator@example.com", "status": "ACTIVE"}]` - st, err := p.Identity(context.Background(), runReturning(mismatch), "ro-sa@proj.iam.gserviceaccount.com") + f := &fakeRun{result: cloud.CLIResult{Stdout: `"token"`}} + st, err := p.Identity(context.Background(), f.run, targetSA) require.NoError(t, err) - assert.Equal(t, "operator@example.com", st.AssumedIdentity) - assert.False(t, st.Valid, "active account differs from the impersonation target") + assert.False(t, st.Valid, "impersonation env pinned to a different SA is invalid") assert.NotEmpty(t, st.Hint) + assert.Empty(t, f.calls, "a mismatched pin short-circuits before the probe") } -func TestIdentityInvalidWhenNoActiveAccount(t *testing.T) { +func TestIdentityValidWhenImpersonatedReadSucceeds(t *testing.T) { + t.Setenv(EnvImpersonate, targetSA) p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) - st, err := p.Identity(context.Background(), runReturning(`[]`), "ro-sa@proj.iam.gserviceaccount.com") + f := &fakeRun{result: cloud.CLIResult{Stdout: `"ya29.token"`}} + st, err := p.Identity(context.Background(), f.run, targetSA) require.NoError(t, err) - assert.Empty(t, st.AssumedIdentity) - assert.False(t, st.Valid) - assert.NotEmpty(t, st.Hint) + 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 TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { +func TestIdentityInvalidWhenImpersonatedReadFails(t *testing.T) { + t.Setenv(EnvImpersonate, targetSA) p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) - st, err := p.Identity(context.Background(), runReturning(authListJSON), "") + 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, "no pinned target means the session is not validly pinned") - assert.NotEmpty(t, st.Hint) + 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) { + t.Setenv(EnvImpersonate, targetSA) 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("gcloud not authenticated") - }) - st, err := p.Identity(context.Background(), failing, "ro-sa@proj.iam.gserviceaccount.com") + 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") } - -func TestIdentityCallsAuthListWithJSONFormat(t *testing.T) { - 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: authListJSON}, nil - }) - _, err = p.Identity(context.Background(), capturing, "ro-sa@proj.iam.gserviceaccount.com") - require.NoError(t, err) - assert.Equal(t, []string{"auth", "list", "--filter=status:ACTIVE", "--format=json"}, gotArgv) -} diff --git a/pkg/mcp/cloud/providers/gcp/inventory.go b/pkg/mcp/cloud/providers/gcp/inventory.go index bdf1cba9..ff36933b 100644 --- a/pkg/mcp/cloud/providers/gcp/inventory.go +++ b/pkg/mcp/cloud/providers/gcp/inventory.go @@ -25,6 +25,9 @@ func (p *Provider) Inventory(ctx context.Context, run cloud.RunFunc) (cloud.Inve 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 { diff --git a/pkg/mcp/cloud/providers/gcp/inventory_test.go b/pkg/mcp/cloud/providers/gcp/inventory_test.go index 66404f48..77fe8c08 100644 --- a/pkg/mcp/cloud/providers/gcp/inventory_test.go +++ b/pkg/mcp/cloud/providers/gcp/inventory_test.go @@ -26,6 +26,12 @@ const projectsListJSON = `[ } ]` +func runReturning(out string) cloud.RunFunc { + return func(context.Context, []string) (cloud.CLIResult, error) { + return cloud.CLIResult{Stdout: out}, nil + } +} + func TestInventoryProjectsIDAndName(t *testing.T) { t.Parallel() p, err := newWithBinary("/usr/bin/gcloud") @@ -64,6 +70,19 @@ func TestInventoryCallsProjectsListWithJSONFormat(t *testing.T) { "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") diff --git a/pkg/mcp/cloud/validate.go b/pkg/mcp/cloud/validate.go index 770b995d..9b8469b3 100644 --- a/pkg/mcp/cloud/validate.go +++ b/pkg/mcp/cloud/validate.go @@ -6,8 +6,11 @@ import ( ) // ScopeAllowlist is the deployment's set of cloud targets any run_cli argv may -// reference. An empty field means that target axis is unconstrained. The agent -// cannot pivot to an un-allowlisted project, account, or region. +// reference. An empty field means that target axis is unconstrained. Project and +// region/zone are enforced here against argv (allowedFor maps --project and +// --region/--zone). Account reach is not validated at the argv layer: it is +// constrained by the pinned identity or role the session assumes, and the +// identity-selecting flags (--account, --profile) sit on the deny floor. type ScopeAllowlist struct { Projects []string `json:"projects,omitempty"` Accounts []string `json:"accounts,omitempty"` @@ -29,15 +32,21 @@ func (s ScopeAllowlist) allowedFor(flag string) ([]string, bool) { } } -// validateArgv enforces the no-bypass contract on one argv before exec: 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 metacharacter tokens -// are inert positionals that simply fail the exact allowlist match. +// 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), " ")) } @@ -76,6 +85,19 @@ func validateArgv(argv []string, allow *CommandAllowlist, scope ScopeAllowlist) 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. diff --git a/pkg/mcp/cloud/validate_test.go b/pkg/mcp/cloud/validate_test.go index ba77a32e..b87f5517 100644 --- a/pkg/mcp/cloud/validate_test.go +++ b/pkg/mcp/cloud/validate_test.go @@ -29,6 +29,10 @@ func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { {"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}, } @@ -58,6 +62,37 @@ func TestValidateArgvEqualsFormFlag(t *testing.T) { "expected --project=prod (equals form, in scope) to validate") } +func TestValidateArgvAllowsResourceOperand(t *testing.T) { + t.Parallel() + al := &CommandAllowlist{Commands: []Command{{Path: "compute instances describe"}}} + scope := ScopeAllowlist{Projects: []string{"prod"}} + // 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", "--project", "prod"}, 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: "projects list"}}} From ff3d56841a5c3f03271b7164f361b7ae22bf9564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 18:54:03 +0200 Subject: [PATCH 24/76] =?UTF-8?q?fix(cloud):=20second=20review=20pass=20?= =?UTF-8?q?=E2=80=94=20GCP=20probe=20regression,=20profile=20validation,?= =?UTF-8?q?=20allowlist=20path,=20identity=20display=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cloud/gcp): validate impersonation via the token read, not process env The launcher-side preflight probe injects the impersonation env only into the MCP subprocess, never into its own os.Environ. Reading os.Getenv in Identity therefore saw an empty value at the launcher and marked every GCP source invalid, so GCP sources were dropped from the session. Rest validity on the impersonated print-access-token read alone, which runs in the subprocess with the env the launcher already injects. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud/aws): display the pinned role ARN in identity status The raw STS caller ARN carries a fresh assumed-role session segment each run, so /api/connections and session_status showed an identity that drifted every session and never matched the configured assumed_identity. When a pinned role ARN is supplied and the resolved role matches it, display the canonical pinned ARN instead. Structural validity is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(preflight): resolve command_allowlist_path against the profile dir The override is documented as relative to profile.yaml, but it was forwarded verbatim into the cloud MCP env and os.ReadFile'd against the subprocess's session-scoped cwd, so a documented relative path failed to load. Resolve a relative command_allowlist_path against the profile dir at load time, mirroring how KindsPath is absolutized; absolute paths pass through unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(profile): validate cloud sources Cloud sources were parsed but never validated, so empty or duplicate aliases, unknown providers, missing assumed_identity, or aws sources without a profile reached preflight. A duplicate or empty alias is the worst: writeMCPConfig keys servers by triagent-cloud-, so a later dup silently overwrites an earlier server, and validCloudSources keys probe results by alias. Extend Profile.Validate to check each cloud source: non-empty unique alias, provider in {gcp, aws}, non-empty assumed_identity, and a non-empty profile for aws sources. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(cloud): describe the impersonation-proving GCP probe The GCP setup and connections docs still said validity requires the active gcloud account to equal the pinned service account. The probe now confirms impersonation is pinned to the expected service account and proves it with a minimal impersonated token read; under impersonation the operator's base account stays active, so account equality is not required and would mark a correctly configured session invalid. Reword both sections to match. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 2 +- docs/content/connections.md | 2 +- internal/profile/embed.go | 18 +++ internal/profile/profile.go | 4 +- internal/profile/profile_test.go | 110 +++++++++++++++++++ internal/profile/validate.go | 30 +++++ pkg/mcp/cloud/providers/aws/identity.go | 18 ++- pkg/mcp/cloud/providers/aws/identity_test.go | 2 + pkg/mcp/cloud/providers/gcp/identity.go | 19 +--- pkg/mcp/cloud/providers/gcp/identity_test.go | 21 +--- 10 files changed, 189 insertions(+), 37 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 4bc6dbff..90faafa4 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -28,7 +28,7 @@ The deployment grants the operator `roles/iam.serviceAccountTokenCreator` on a r The profile pins that service account as `assumed_identity`. 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 only when the active `gcloud` account equals the pinned impersonation target. +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 diff --git a/docs/content/connections.md b/docs/content/connections.md index 8121d0c1..c225e819 100644 --- a/docs/content/connections.md +++ b/docs/content/connections.md @@ -65,7 +65,7 @@ incidents by passing a different `incident_id`. 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 pinned `assumed_identity` and a validity state. Validity comes from an identity probe run on panel load: GCP checks that the active account equals the impersonated 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. +Each pill shows the pinned `assumed_identity` 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. diff --git a/internal/profile/embed.go b/internal/profile/embed.go index c83968fd..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 diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 3ea4474c..e5ea9532 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -168,7 +168,9 @@ type CloudSource struct { Profile string `yaml:"profile,omitempty"` // aws AWS_PROFILE selector; ignored by gcp 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. + // 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"` } diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 7e417cc3..c32d99e6 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -237,6 +237,76 @@ 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"}, + {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "ro"}, + } + 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", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "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 TestValidateCloudAWSMissingProfile(t *testing.T) { + p := validCloudBase() + p.Cloud = []profile.CloudSource{ + {Alias: "x", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro"}, + } + err := p.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "profile") +} + func TestDefaultProfilePromptsPopulated(t *testing.T) { p, err := profile.LoadEmbedded("default") if err != nil { @@ -520,6 +590,46 @@ 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 + assumed_identity: arn:aws:iam::111122223333:role/ro + profile: 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 diff --git a/internal/profile/validate.go b/internal/profile/validate.go index 463bcc93..d3103b90 100644 --- a/internal/profile/validate.go +++ b/internal/profile/validate.go @@ -59,6 +59,36 @@ 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, missing identity, or aws source + // without a profile reaches preflight as a broken connection. Catch all of + // it here. + 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", "aws": + 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 c.AssumedIdentity == "" { + errs = append(errs, fmt.Sprintf("cloud[%d].assumed_identity: required", i)) + } + if c.Provider == "aws" && c.Profile == "" { + errs = append(errs, fmt.Sprintf("cloud[%d].profile: required when provider=aws", i)) + } + } + if len(errs) == 0 { return nil } diff --git a/pkg/mcp/cloud/providers/aws/identity.go b/pkg/mcp/cloud/providers/aws/identity.go index 9ba94cc4..9bfd0be8 100644 --- a/pkg/mcp/cloud/providers/aws/identity.go +++ b/pkg/mcp/cloud/providers/aws/identity.go @@ -23,10 +23,13 @@ type callerIdentity struct { // 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. 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. +// 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 { @@ -51,6 +54,13 @@ func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc, expected str 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 } diff --git a/pkg/mcp/cloud/providers/aws/identity_test.go b/pkg/mcp/cloud/providers/aws/identity_test.go index 1eff919b..d20e35f8 100644 --- a/pkg/mcp/cloud/providers/aws/identity_test.go +++ b/pkg/mcp/cloud/providers/aws/identity_test.go @@ -75,6 +75,8 @@ func TestIdentityMatchesExpectedRoleArnWhenPinned(t *testing.T) { 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) { diff --git a/pkg/mcp/cloud/providers/gcp/identity.go b/pkg/mcp/cloud/providers/gcp/identity.go index 75d9466d..cb218aa5 100644 --- a/pkg/mcp/cloud/providers/gcp/identity.go +++ b/pkg/mcp/cloud/providers/gcp/identity.go @@ -2,7 +2,6 @@ package gcp import ( "context" - "os" "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) @@ -15,10 +14,12 @@ import ( // SA, so comparing the base account to the target would mark a correctly // configured session invalid. // -// The probe reads the in-process impersonation env (the launcher sets it on the -// subprocess; the agent cannot reach it), confirms it is pinned to the expected -// target, then runs a minimal impersonated read to prove the pin took effect. A -// degraded auth state surfaces through Valid and Hint, never a Go error. +// 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. @@ -31,14 +32,6 @@ func (p *Provider) Identity(ctx context.Context, run cloud.RunFunc, expected str return st, nil } - pinned := os.Getenv(EnvImpersonate) - if pinned != expected { - st.Valid = false - st.Hint = EnvImpersonate + " is not pinned to the expected identity " + expected + - "; the launcher must set it 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"}) diff --git a/pkg/mcp/cloud/providers/gcp/identity_test.go b/pkg/mcp/cloud/providers/gcp/identity_test.go index 063d583c..f0324f9f 100644 --- a/pkg/mcp/cloud/providers/gcp/identity_test.go +++ b/pkg/mcp/cloud/providers/gcp/identity_test.go @@ -26,7 +26,6 @@ func (f *fakeRun) run(_ context.Context, argv []string) (cloud.CLIResult, error) } func TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { - t.Setenv(EnvImpersonate, targetSA) p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) @@ -38,21 +37,11 @@ func TestIdentityInvalidWhenNoImpersonationTargetPinned(t *testing.T) { assert.Empty(t, f.calls, "no probe should run without a target") } -func TestIdentityInvalidWhenImpersonationEnvNotPinnedToExpected(t *testing.T) { - t.Setenv(EnvImpersonate, "someone-else@proj.iam.gserviceaccount.com") - 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, targetSA) - require.NoError(t, err) - assert.False(t, st.Valid, "impersonation env pinned to a different SA is invalid") - assert.NotEmpty(t, st.Hint) - assert.Empty(t, f.calls, "a mismatched pin short-circuits before the probe") -} - func TestIdentityValidWhenImpersonatedReadSucceeds(t *testing.T) { - t.Setenv(EnvImpersonate, targetSA) + // 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) @@ -67,7 +56,6 @@ func TestIdentityValidWhenImpersonatedReadSucceeds(t *testing.T) { } func TestIdentityInvalidWhenImpersonatedReadFails(t *testing.T) { - t.Setenv(EnvImpersonate, targetSA) p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) @@ -83,7 +71,6 @@ func TestIdentityInvalidWhenImpersonatedReadFails(t *testing.T) { } func TestIdentitySurfacesRunErrorAsHint(t *testing.T) { - t.Setenv(EnvImpersonate, targetSA) p, err := newWithBinary("/usr/bin/gcloud") require.NoError(t, err) From 9f59281787eebb6cf70c36414d7537973e1eb702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 19:45:49 +0200 Subject: [PATCH 25/76] docs(cloud): recommend a minimal read-only IAM grant for the pinned identity (#60) A fresh-reader pass (writing-docs) found a deployment admin could not learn what read-only IAM to grant the pinned identity from the docs: the GCP read roles, the AWS permission/trust policy, and the IAM-vs-allowlist relationship were all absent. Add, derived from each provider's default_commands.json: - GCP: the minimal predefined read roles for the service account (browser, compute.viewer, container.viewer, iam.securityReviewer, logging.viewer, monitoring.viewer), made distinct from the operator's serviceAccountTokenCreator impersonation grant, with roles/viewer as the broad alternative. - AWS: a least-privilege permission policy scoped to the default tool surface plus the assume-role trust policy, with managed ReadOnlyAccess as the alternative. - The rule that the IAM grant and the command allowlist are independent layers that must stay aligned (widen the grant if you widen the allowlist), and that list_allowed_commands is the authoritative per-source command list. Co-authored-by: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 90faafa4..3ce5bb17 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -26,6 +26,24 @@ 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*. The service account needs read-only access on each project in the source's scope. The minimal set of predefined roles covering the default tool surface (inventory, reachability, IAM read, GKE, logs, audit): + +```sh +SA=triage-readonly@prod.iam.gserviceaccount.com +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 prod-platform \ + --member="serviceAccount:$SA" --role="$role" +done +``` + +`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`. 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. @@ -49,6 +67,54 @@ region = eu-west-1 The profile's `profile:` field selects that assume-role profile via `AWS_PROFILE`, and `assumed_identity` is the expected role ARN. The harness sets `AWS_PROFILE=` on the cloud MCP subprocess, so the AWS CLI assumes the read-only role from the operator's base credentials. As with GCP, the pin lives in environment, so `--profile` stays on the agent's deny floor. +The 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 lets the operator's base principal assume the role: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::123456789012:root" }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +Scope the trust `Principal` to the specific operator users or SSO role rather than the whole account root where you can. 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. + 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 pinned `assumed_identity`. A plain user or root ARN means the assume-role pin did not take effect and base credentials leaked through, so the source degrades. ## The `cloud:` profile block @@ -121,6 +187,8 @@ What the agent can run through `run_cli` is governed by a positive command allow 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. The floor covers 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`), and argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). A too-broad allowlist override cannot punch through it. +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//`. + ## Visible degrade 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 cloud source. The connections panel shows the source unavailable with a re-auth hint, and the session starts with the source disabled and visibly marked unavailable. The Kubernetes investigation proceeds without the cloud axis. From de57731f49118cc0a3008afdbd7db2ab1c90c36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sat, 30 May 2026 20:08:40 +0200 Subject: [PATCH 26/76] =?UTF-8?q?fix(cloud):=20third=20review=20pass=20?= =?UTF-8?q?=E2=80=94=20deny-floor=20nested=20exfil=20paths,=20file-flags,?= =?UTF-8?q?=20degraded-source=20identity=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cloud): deny-floor --flags-file and --access-token-file --flags-file makes gcloud read additional flags from a file after validateArgv has run, reintroducing denied identity/endpoint flags; --access-token-file selects credential material from a file. Add both to the base deny-floor flags so the floor catches them on argv. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud): deny-floor nested secret/exfil/decrypt commands in both providers The base floor prefix-matches top-level tokens, so its secrets/cp/sync entries never reach nested provider verbs. A too-broad profile override could re-enable secret-value reads, bucket-object downloads, and decrypt. Add the full token-wise paths to each provider's DenyFloorAdditions so filterAllowlist drops them from any allowlist: AWS: secretsmanager get-secret-value; s3 cp/mv/sync; s3api get-object/get-object-attributes/get-object-torrent; kms decrypt; ssm get-parameter/get-parameters/get-parameters-by-path. GCP: storage cp/mv/rsync/cat; kms decrypt. Metadata-only reads (describe-secret, list-secrets, head-object, describe-parameters, describe-key, storage ls/buckets describe, kms keys list) stay allowable: the floor targets secret VALUES, object CONTENTS, and decryption, not listing or describing. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(connections): show the pinned identity for a degraded cloud source When provider construction or the probe fails before resolving an identity, the probe status leaves Provider and AssumedIdentity blank, so the /api/connections cloud entry lost the configured pinned identity for an unavailable source. Fall back to the profile source's Provider and AssumedIdentity (the alias is always the source's) whenever the probe leaves them blank, so operators still see WHICH identity was configured alongside valid:false and the failure hint. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(spec): correct the account-scoping enforcement bullet The scope-validation bullet claimed --account values are scope-validated on argv. The as-built contract deny-floors --account/--profile and treats ScopeAllowlist.Accounts as informational: project and region/zone are the argv-validated axes, and AWS account reach is bounded by the pinned assume-role profile's role_arn, not by an argv flag. Match the bullet to shipped behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../2026-05-30-cloud-context-mcp-design.md | 2 +- internal/server/handlers_connections.go | 16 ++++- internal/server/handlers_connections_test.go | 45 +++++++++++++ pkg/mcp/cloud/allowlist.go | 1 + pkg/mcp/cloud/providers/aws/provider.go | 22 +++++-- pkg/mcp/cloud/providers/aws/provider_test.go | 66 +++++++++++++++++++ pkg/mcp/cloud/providers/gcp/provider.go | 13 +++- pkg/mcp/cloud/providers/gcp/provider_test.go | 58 ++++++++++++++++ pkg/mcp/cloud/validate_test.go | 2 + 9 files changed, 217 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md index 9ff834de..39aced7d 100644 --- a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md +++ b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md @@ -65,7 +65,7 @@ The security model is the heart of this feature. It has two independent layers: - **Direct `execve`, no shell.** The harness execs the provider's fixed binary with the argv array (`exec.CommandContext`). No `sh -c` exists anywhere in the package. Shell metacharacters (`|`, `;`, `&&`, `$(…)`, backticks, `>`, newlines) have meaning only to a shell; handed to `gcloud`/`aws` as literal argv tokens they are inert and rejected by the binary. A unit test asserts no `sh -c` / `bash -c` construction exists and that an argv full of metacharacters never spawns a second process. - **Positive allowlist on the normalized subcommand path** (for example `compute firewall-rules list`, `projects list`), loaded from an embedded default JSON overridable via a profile-pointed path. This is the `LoadAllowlist` pattern from `pkg/mcp/k8s/allowlist.go`: embedded default, optional override, applied identically. - **A hardcoded deny floor the config can never re-enable**, mirroring how `LoadAllowlist` always filters `Secret` regardless of the kinds config. The floor covers dangerous subcommands (`secrets ... access`, `ssh`/`scp`, `cp`/`sync`, `auth`, `config set`) and dangerous flags (`--impersonate-service-account`, `--account`, `--profile`, `--endpoint-url`, `--cli-input-*`, `--configuration`), plus argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). -- **Scope validation.** Any `--project` / `--account` / region in the argv must be in the profile's scope allowlist, so the agent cannot pivot to an un-allowlisted target. +- **Scope validation.** Any `--project` and region/zone (`--region` / `--zone`) in the argv must be in the profile's scope allowlist, so the agent cannot pivot to an un-allowlisted target. Account selection is not scope-validated on argv: `--account` and `--profile` are deny-floored, and account reach is constrained by the pinned identity (`ScopeAllowlist.Accounts` is informational — the AWS account an agent can touch is bounded by the assume-role profile's `role_arn`, not by an argv flag). - **Output truncation** keeps a raw response from blowing the context budget. - **Pinned binary and minimal env.** The binary is resolved to an absolute path once at startup; the subprocess runs with an explicit minimal `cmd.Env` (so a poisoned `PATH` cannot substitute a different binary) and closed stdin (no interactive prompt or fed input). diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index 41eb43fb..c06d9ec5 100644 --- a/internal/server/handlers_connections.go +++ b/internal/server/handlers_connections.go @@ -77,10 +77,22 @@ func (a *apiHandlers) cloudConnections(ctx context.Context) []cloudConnection { out := make([]cloudConnection, 0, len(a.prof.Cloud)) for _, src := range a.prof.Cloud { st := probe(ctx, src) + // A probe that fails before resolving an identity leaves Provider and + // AssumedIdentity blank. Fall back to the configured source values so a + // degraded source still shows WHICH identity was pinned alongside its + // failure hint; the alias is always the source's. + provider := st.Provider + if provider == "" { + provider = src.Provider + } + identity := st.AssumedIdentity + if identity == "" { + identity = src.AssumedIdentity + } out = append(out, cloudConnection{ Alias: src.Alias, - Provider: st.Provider, - AssumedIdentity: st.AssumedIdentity, + Provider: provider, + AssumedIdentity: identity, Valid: st.Valid, Hint: st.Hint, }) diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go index e6243f57..4047e719 100644 --- a/internal/server/handlers_connections_test.go +++ b/internal/server/handlers_connections_test.go @@ -335,6 +335,51 @@ func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { assert.Equal(t, "run: aws sso login", resp.Cloud[1].Hint) } +// TestGetConnections_DegradedSource_KeepsConfiguredIdentity asserts that when the +// probe fails before resolving an identity (empty Provider/AssumedIdentity), the +// cloud entry falls back to the profile source's configured provider, assumed +// identity, and alias, so the operator still sees WHICH identity was configured +// alongside valid:false and the failure hint. +func TestGetConnections_DegradedSource_KeepsConfiguredIdentity(t *testing.T) { + t.Parallel() + prof := &profile.Profile{ + Cloud: []profile.CloudSource{ + {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "ro"}, + }, + } + a := &apiHandlers{ + connections: connections.NewWithDir(t.TempDir()), + prof: prof, + // Provider construction failed before resolving an identity: 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: "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"` + Valid bool `json:"valid"` + Hint string `json:"hint"` + } `json:"cloud"` + } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + require.Len(t, resp.Cloud, 1) + assert.Equal(t, "prod-aws", resp.Cloud[0].Alias) + assert.Equal(t, "aws", resp.Cloud[0].Provider, "degraded source must fall back to configured provider") + assert.Equal(t, "arn:aws:iam::1:role/ro", resp.Cloud[0].AssumedIdentity, "degraded source must fall back to configured identity") + assert.False(t, resp.Cloud[0].Valid) + assert.Equal(t, "run: aws sso login", resp.Cloud[0].Hint) +} + func TestGetConnections_NoCloudSources_OmitsOrEmptyCloud(t *testing.T) { t.Parallel() a := newConnectionsAPI(t) diff --git a/pkg/mcp/cloud/allowlist.go b/pkg/mcp/cloud/allowlist.go index 7379a35a..f52285d9 100644 --- a/pkg/mcp/cloud/allowlist.go +++ b/pkg/mcp/cloud/allowlist.go @@ -52,6 +52,7 @@ var denyFloor = DenyFloor{ Flags: []string{ "--impersonate-service-account", "--account", "--profile", "--endpoint-url", "--cli-input-json", "--cli-input-yaml", "--configuration", + "--flags-file", "--access-token-file", }, ArgPrefixes: []string{"file://", "fileb://", "@", "http://", "https://"}, } diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go index c7dcc0f6..d954adff 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -80,10 +80,13 @@ func (p *Provider) Binary() string { return p.binary } // reachability, permissions, cluster, logs, audit). func (p *Provider) DefaultAllowlist() *cloud.CommandAllowlist { return p.allowlist } -// DenyFloorAdditions contributes the AWS-specific subcommands that return -// credential material or shell access beyond the base floor. The base floor -// already covers the secrets/ssh/auth/config families and identity flags; these -// add the credential-returning reads unique to AWS. +// 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{ @@ -92,6 +95,17 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { "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", }, } } diff --git a/pkg/mcp/cloud/providers/aws/provider_test.go b/pkg/mcp/cloud/providers/aws/provider_test.go index 18a3b42b..0df882a5 100644 --- a/pkg/mcp/cloud/providers/aws/provider_test.go +++ b/pkg/mcp/cloud/providers/aws/provider_test.go @@ -2,7 +2,11 @@ package aws import ( "context" + "encoding/json" "errors" + "os" + "path/filepath" + "strings" "testing" "github.com/sourcehawk/triagent/pkg/mcp/cloud" @@ -63,6 +67,68 @@ func TestDenyFloorAdditionsCoverCredentialReturningCommands(t *testing.T) { 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) diff --git a/pkg/mcp/cloud/providers/gcp/provider.go b/pkg/mcp/cloud/providers/gcp/provider.go index e4d78265..8f13cfdf 100644 --- a/pkg/mcp/cloud/providers/gcp/provider.go +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -65,7 +65,13 @@ func (p *Provider) Binary() string { return p.binary } func (p *Provider) DefaultAllowlist() *cloud.CommandAllowlist { return p.allowlist } // DenyFloorAdditions contributes gcp-specific subcommands that read credentials, -// shell into instances, or mutate by side effect, on top of the base floor. +// 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{ @@ -73,6 +79,11 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { "compute scp", "compute reset-windows-password", "functions call", + "storage cp", + "storage mv", + "storage rsync", + "storage cat", + "kms decrypt", }, } } diff --git a/pkg/mcp/cloud/providers/gcp/provider_test.go b/pkg/mcp/cloud/providers/gcp/provider_test.go index 18c17026..5c1f5445 100644 --- a/pkg/mcp/cloud/providers/gcp/provider_test.go +++ b/pkg/mcp/cloud/providers/gcp/provider_test.go @@ -1,8 +1,13 @@ 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" ) @@ -66,3 +71,56 @@ func TestDenyFloorAdditionsCoverDangerousGCPSubcommands(t *testing.T) { 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/validate_test.go b/pkg/mcp/cloud/validate_test.go index b87f5517..199a40e5 100644 --- a/pkg/mcp/cloud/validate_test.go +++ b/pkg/mcp/cloud/validate_test.go @@ -23,6 +23,8 @@ func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { {"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}, {"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}, From ad8b311a4556612c1c122dd3dd6fd0770b3c8f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 02:34:59 +0200 Subject: [PATCH 27/76] fix(cloud): close deny-floor prefix bypass and bound the identity probe (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cloud): drop allowlist entries that are a prefix of a denied path filterAllowlist dropped an override only when a deny-floor subcommand was a token-prefix of the entry (the entry sat UNDER a denied path). An override that was a PREFIX OF a denied path survived, yet Allows then re-admitted the floored nested command via its prefix match: a bare "s3" entry made Allows(["s3","cp", ...]) true again, re-enabling the floored "s3 cp". validateArgv never re-checks floored subcommands at runtime, so the load-time filter was the sole gate. Fold both directions into DenyFloor.blocks: drop an entry when it is prefix-comparable to any floor subcommand either way. Entries that share a leading token but diverge deeper (compute instances list vs floored compute ssh) are prefix-comparable to neither and stay allowed; no shipped default_commands.json entry is dropped by the stricter filter. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud): bound the identity probe with a timeout so a hung CLI degrades ProbeSource ran the provider whoami under the caller's context with no bound, so a stale SSO flow, a slow network, or a wedged gcloud/aws blocked /api/connections and session preflight indefinitely — breaking the "degrade, never block" contract. Wrap the probe in a 15s timeout (comfortably above a normal 1-3s whoami, well below anything that would stall a request): the deadline kills the CLI exec, the provider surfaces the context error, and cloud.Probe degrades it to a Valid:false status with a hint rather than hanging. Extract probeProvider so the bound is observable without a real CLI; a blocking fake provider plus a shortened probeTimeout proves the deadline propagates. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/allowlist.go | 24 +++++++++++----- pkg/mcp/cloud/allowlist_test.go | 36 ++++++++++++++++++++++++ pkg/mcp/cloud/providers/probe.go | 22 ++++++++++++++- pkg/mcp/cloud/providers/probe_test.go | 40 +++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/pkg/mcp/cloud/allowlist.go b/pkg/mcp/cloud/allowlist.go index f52285d9..f9f025c0 100644 --- a/pkg/mcp/cloud/allowlist.go +++ b/pkg/mcp/cloud/allowlist.go @@ -102,7 +102,7 @@ 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.deniesSubcommand(normalizePath(c.Path)) { + if c.Path == "" || floor.blocks(normalizePath(c.Path)) { continue } out.Commands = append(out.Commands, c) @@ -127,13 +127,23 @@ func (a *CommandAllowlist) Allows(argv []string) bool { return false } -// deniesSubcommand reports whether a normalized subcommand path falls under any -// floored subcommand. A floor entry matches when it is a token-wise prefix of -// the path, so "secrets" floors "secrets versions access" and "compute ssh" -// floors "compute ssh foo". -func (d DenyFloor) deniesSubcommand(path []string) bool { +// 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 { - if pathHasPrefix(path, normalizePath(s)) { + floor := normalizePath(s) + if pathHasPrefix(path, floor) || pathHasPrefix(floor, path) { return true } } diff --git a/pkg/mcp/cloud/allowlist_test.go b/pkg/mcp/cloud/allowlist_test.go index 295cc04c..fcc4c6d7 100644 --- a/pkg/mcp/cloud/allowlist_test.go +++ b/pkg/mcp/cloud/allowlist_test.go @@ -48,6 +48,42 @@ func TestLoadCommandAllowlistMergesProviderDenyFloorAdditions(t *testing.T) { "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"}}} diff --git a/pkg/mcp/cloud/providers/probe.go b/pkg/mcp/cloud/providers/probe.go index 98d8827c..8106b69d 100644 --- a/pkg/mcp/cloud/providers/probe.go +++ b/pkg/mcp/cloud/providers/probe.go @@ -4,12 +4,22 @@ 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 @@ -37,8 +47,18 @@ func ProbeSource(ctx context.Context, src Source) cloud.IdentityStatus { if err != nil { return cloud.IdentityStatus{Provider: src.Provider, Valid: false, Hint: err.Error()} } + return probeProvider(ctx, p, src.AssumedIdentity, sourceEnvFor(p, src)) +} + +// 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, src.AssumedIdentity, sourceEnvFor(p, src)) + st, _ := cloud.Probe(ctx, p, expected, env) return st } diff --git a/pkg/mcp/cloud/providers/probe_test.go b/pkg/mcp/cloud/providers/probe_test.go index 08c0498c..4c484ac1 100644 --- a/pkg/mcp/cloud/providers/probe_test.go +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -4,13 +4,53 @@ import ( "context" "os" "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) 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 From a32040f8b8052a75c3d1b4ca00a13edb721defa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 02:54:48 +0200 Subject: [PATCH 28/76] docs(cloud): clarify scope is a guardrail and read-only rests on the IAM floor (#65) Addresses two review findings: scope only constrains explicit --project/--region values (omission falls back to the CLI default, so hard project confinement is the per-project IAM grant), and allowlist entries must be leaf read-verbs (an intermediate override would admit mutating siblings via prefix match; the no-write guarantee is the read-only IAM grant, not the allowlist alone). Co-authored-by: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 3ce5bb17..64f52ced 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -177,6 +177,8 @@ scope: 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. +Scope constrains the value of an explicit flag; it does not force one to be present. If the agent omits `--project`, the CLI falls back to its own default target (the impersonated identity's default project, `CLOUDSDK_CORE_PROJECT`, or for AWS the configured `AWS_REGION`), which scope does not police. Hard project confinement therefore comes from the pinned identity's IAM, not from scope: grant the read-only roles only on the in-scope projects, as the setup above does, so an out-of-scope project is unreachable whatever the argv. Region has no equivalent IAM boundary, so 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-selecting flags (`--account`, `--profile`) never reach scope validation at all, because the deny floor rejects them first. @@ -185,6 +187,8 @@ Identity-selecting flags (`--account`, `--profile`) never reach scope validation 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. +Allowlist entries must be complete leaf verbs, for example `compute instances list` or `ec2 describe-security-groups`, never an intermediate group path like `compute instances` or `ec2`. The allowlist matches an entry as a prefix of the command, so an intermediate entry would also admit its sibling verbs, including mutating ones (`compute instances delete`, `ec2 terminate-instances`). 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: 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. The floor covers 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`), and argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). A too-broad allowlist override cannot punch through it. 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//`. From 453a5acc7c104e8bcd164033544ec7fe084c8515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 03:27:51 +0200 Subject: [PATCH 29/76] =?UTF-8?q?fix(cloud):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20bounded=20output,=20degraded-identity=20reporting,?= =?UTF-8?q?=20absolute=20binary,=20doc/field=20cleanup=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cloud): cap harness output during the run instead of buffering unbounded execCLI buffered the full stdout/stderr in memory and truncated only after the process returned, so a command emitting a very large response could consume unbounded memory despite defaultOutputLimit. Capture stdout/stderr through a bounded limitedWriter that retains at most limit bytes each and records overflow, so the cap is effective during the run. Every existing guarantee is preserved: no shell, explicit minimal env, closed stdin, Truncated set on overflow, stderr captured and capped, non-zero exit as a normal CLIResult, real start/exec failure as a Go error. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud): report the pinned identity on a degraded probe When the provider failed to resolve an identity, Probe returned Valid:false with an empty AssumedIdentity even though the caller passed the pinned identity in expected, so session_status no longer named which pinned identity was degraded. Fall back to expected whenever the resulting status has an empty AssumedIdentity, on both the degraded and valid paths, so the displayed identity is always the pinned one. Degrade-never-error semantics are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud): keep the pinned identity when provider construction fails A provider construction failure (e.g. a missing gcloud/aws binary) returned IdentityStatus{Provider, Valid:false, Hint} with no AssumedIdentity, so preflight and connections reported the degraded source without the identity the operator must fix. Carry src.AssumedIdentity through the construction-error status, mirroring the probe-path fallback so both ProbeSource exits name the pinned identity. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud): resolve the provider CLI to an absolute path The harness relies on a fixed absolute binary path so a later subprocess env/PATH change cannot redirect what executes, but exec.LookPath returns a relative path (flagged with exec.ErrDot) when PATH carries relative entries. Pass the LookPath result through filepath.Abs in each provider's New(), recovering the relative path on ErrDot and erroring if it still cannot be made absolute. Applied identically to gcp and aws. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(cloud): correct CLIResult doc to say raw truncated output The comment claimed output was shaped/redacted, but run_cli returns the provider CLI's raw stdout/stderr, only truncated. State that CLIResult carries the raw CLI stdout (and stderr), capped at the output limit with Truncated set when exceeded, so callers do not assume shaping or redaction beyond truncation. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(cloud): drop the unused Command.Redact field Command.Redact was advertised in the allowlist schema and documented as marking output for secret-scrubbing, but nothing read it before returning run_cli output, so it promised protection that did not exist. No shipped default_commands.json sets it. Remove the field and its doc; run_cli is the gated escape hatch returning raw (truncated) output by design, and typed tools are where projection lives. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/allowlist.go | 3 +- pkg/mcp/cloud/harness.go | 68 +++++++++++++------- pkg/mcp/cloud/harness_test.go | 16 +++++ pkg/mcp/cloud/probe.go | 12 ++-- pkg/mcp/cloud/probe_test.go | 28 ++++++++ pkg/mcp/cloud/provider.go | 6 +- pkg/mcp/cloud/providers/aws/provider.go | 15 ++++- pkg/mcp/cloud/providers/aws/provider_test.go | 25 +++++++ pkg/mcp/cloud/providers/gcp/provider.go | 15 ++++- pkg/mcp/cloud/providers/gcp/provider_test.go | 25 +++++++ pkg/mcp/cloud/providers/probe.go | 7 +- pkg/mcp/cloud/providers/probe_test.go | 15 +++++ 12 files changed, 197 insertions(+), 38 deletions(-) diff --git a/pkg/mcp/cloud/allowlist.go b/pkg/mcp/cloud/allowlist.go index f9f025c0..ac5444c8 100644 --- a/pkg/mcp/cloud/allowlist.go +++ b/pkg/mcp/cloud/allowlist.go @@ -19,11 +19,10 @@ 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). Redact marks output that needs secret-scrubbing. +// command serves (prose only). type Command struct { Path string `json:"path"` Description string `json:"description,omitempty"` - Redact bool `json:"redact,omitempty"` } // CommandAllowlist is the decoded allowlist document: the positive set of diff --git a/pkg/mcp/cloud/harness.go b/pkg/mcp/cloud/harness.go index 8d11dc06..8c2b11d3 100644 --- a/pkg/mcp/cloud/harness.go +++ b/pkg/mcp/cloud/harness.go @@ -1,7 +1,6 @@ package cloud import ( - "bytes" "context" "errors" "os/exec" @@ -11,40 +10,63 @@ import ( // 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 capped at limit. 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. +// 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 - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + stdout := &limitedWriter{limit: limit} + stderr := &limitedWriter{limit: limit} + cmd.Stdout = stdout + cmd.Stderr = stderr err := cmd.Run() - res := CLIResult{} - out := stdout.Bytes() - if len(out) > limit { - out = out[:limit] - res.Truncated = true - } - res.Stdout = string(out) - - errOut := stderr.Bytes() - if len(errOut) > limit { - errOut = errOut[:limit] - res.Truncated = true + res := CLIResult{ + Stdout: string(stdout.buf), + Stderr: string(stderr.buf), + Truncated: stdout.overflow || stderr.overflow, } - res.Stderr = string(errOut) if err != nil { var exitErr *exec.ExitError diff --git a/pkg/mcp/cloud/harness_test.go b/pkg/mcp/cloud/harness_test.go index 0d4861f1..79a0f5b5 100644 --- a/pkg/mcp/cloud/harness_test.go +++ b/pkg/mcp/cloud/harness_test.go @@ -41,3 +41,19 @@ func TestExecCLITruncatesStderr(t *testing.T) { 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 index 6a90fd39..38f65a85 100644 --- a/pkg/mcp/cloud/probe.go +++ b/pkg/mcp/cloud/probe.go @@ -30,9 +30,10 @@ func Probe(ctx context.Context, p Provider, expected string, env []string) (Iden st, err := p.Identity(ctx, run, expected) if err != nil { return IdentityStatus{ - Provider: p.Name(), - Valid: false, - Hint: err.Error(), + Provider: p.Name(), + AssumedIdentity: expected, + Valid: false, + Hint: err.Error(), }, nil } if st.Provider == "" { @@ -40,8 +41,11 @@ func Probe(ctx context.Context, p Provider, expected string, env []string) (Iden } if st.AssumedIdentity == "" { // A whoami that resolved no identity is not a valid session, whatever - // the provider reported. + // 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 index a2cf8aea..4925233b 100644 --- a/pkg/mcp/cloud/probe_test.go +++ b/pkg/mcp/cloud/probe_test.go @@ -111,3 +111,31 @@ func TestProbeInvalidWhenIdentityEmpty(t *testing.T) { 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 index 111ebad1..32d1bee1 100644 --- a/pkg/mcp/cloud/provider.go +++ b/pkg/mcp/cloud/provider.go @@ -69,8 +69,10 @@ type IdentityStatus struct { Hint string `json:"hint,omitempty"` } -// CLIResult is the shaped result of one run_cli invocation. Raw provider JSON -// is never surfaced; the harness caps output and reports truncation. +// 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"` diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go index d954adff..85da7afc 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -13,8 +13,10 @@ package aws import ( _ "embed" "encoding/json" + "errors" "fmt" "os/exec" + "path/filepath" "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) @@ -50,13 +52,20 @@ type Provider struct { } // 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. +// 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() (*Provider, error) { bin, err := exec.LookPath("aws") - if err != nil { + if err != nil && !errors.Is(err, exec.ErrDot) { return nil, fmt.Errorf("aws: resolve aws binary: %w", err) } - return newWithBinary(bin) + abs, err := filepath.Abs(bin) + if err != nil { + return nil, fmt.Errorf("aws: resolve aws binary to absolute path: %w", err) + } + return newWithBinary(abs) } // newWithBinary builds the provider against an already-resolved binary path. It diff --git a/pkg/mcp/cloud/providers/aws/provider_test.go b/pkg/mcp/cloud/providers/aws/provider_test.go index 0df882a5..b3efd361 100644 --- a/pkg/mcp/cloud/providers/aws/provider_test.go +++ b/pkg/mcp/cloud/providers/aws/provider_test.go @@ -23,6 +23,31 @@ func TestNewResolvesProvider(t *testing.T) { 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) diff --git a/pkg/mcp/cloud/providers/gcp/provider.go b/pkg/mcp/cloud/providers/gcp/provider.go index 8f13cfdf..3b819ead 100644 --- a/pkg/mcp/cloud/providers/gcp/provider.go +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -8,8 +8,10 @@ package gcp import ( _ "embed" "encoding/json" + "errors" "fmt" "os/exec" + "path/filepath" "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) @@ -36,13 +38,20 @@ type Provider struct { } // 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. +// 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() (*Provider, error) { bin, err := exec.LookPath("gcloud") - if err != nil { + if err != nil && !errors.Is(err, exec.ErrDot) { return nil, fmt.Errorf("gcp: resolve gcloud binary: %w", err) } - return newWithBinary(bin) + abs, err := filepath.Abs(bin) + if err != nil { + return nil, fmt.Errorf("gcp: resolve gcloud binary to absolute path: %w", err) + } + return newWithBinary(abs) } // newWithBinary builds the provider against an already-resolved binary path. It diff --git a/pkg/mcp/cloud/providers/gcp/provider_test.go b/pkg/mcp/cloud/providers/gcp/provider_test.go index 5c1f5445..2544384b 100644 --- a/pkg/mcp/cloud/providers/gcp/provider_test.go +++ b/pkg/mcp/cloud/providers/gcp/provider_test.go @@ -20,6 +20,31 @@ func TestNewResolvesBinaryAndName(t *testing.T) { 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") diff --git a/pkg/mcp/cloud/providers/probe.go b/pkg/mcp/cloud/providers/probe.go index 8106b69d..2273a520 100644 --- a/pkg/mcp/cloud/providers/probe.go +++ b/pkg/mcp/cloud/providers/probe.go @@ -45,7 +45,12 @@ type Source struct { func ProbeSource(ctx context.Context, src Source) cloud.IdentityStatus { p, err := New(src.Provider) if err != nil { - return cloud.IdentityStatus{Provider: src.Provider, Valid: false, Hint: err.Error()} + return cloud.IdentityStatus{ + Provider: src.Provider, + AssumedIdentity: src.AssumedIdentity, + Valid: false, + Hint: err.Error(), + } } return probeProvider(ctx, p, src.AssumedIdentity, sourceEnvFor(p, src)) } diff --git a/pkg/mcp/cloud/providers/probe_test.go b/pkg/mcp/cloud/providers/probe_test.go index 4c484ac1..8d54a8b8 100644 --- a/pkg/mcp/cloud/providers/probe_test.go +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -84,6 +84,21 @@ func TestProbeSourceUnknownProviderDegrades(t *testing.T) { 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) +} + // 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 } From 5117d4c7fdf3a8b6e4cb70ee228d2e3eef5dba5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 03:50:13 +0200 Subject: [PATCH 30/76] docs(spec): bounded target selection (set_active_target) for the cloud-context MCP Amends the identity model from a single pinned target to a deployment-pinned SET the agent may select among (never beyond): a new set_active_target tool, applied as an MCP-controlled per-exec env var (CLOUDSDK_CORE_PROJECT for gcp, AWS_PROFILE for aws), with an AWS accounts list + generated profiles for multi-account. Records the rejected runtime-AssumeRole broker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-cloud-context-mcp-design.md | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md index 39aced7d..975fc3fd 100644 --- a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md +++ b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md @@ -13,25 +13,26 @@ This spec defines a single read-only cloud-context MCP that gives the agent that - Let the operator agent answer cloud-context questions (reachability, permissions, cluster setup, logs, audit trail, inventory) for GCP and AWS from inside an investigation, without a human leaving the loop. - Make adding coverage a config edit, not new Go; make adding a cloud a new provider behind one interface, not a parallel MCP. - Guarantee read-only by construction and by harness, with a safety boundary the agent provably cannot bypass. -- Pin the cloud identity to a deployment-chosen, read-only principal that the agent can neither select nor escalate. +- Pin cloud access to a deployment-chosen set of read-only identities and targets. The agent may select which configured target it operates in (a project for GCP, an account for AWS), but can neither reach outside that set nor escalate privilege within it. - Surface cloud auth readiness before a session starts, so the operator fixes a stale credential proactively rather than discovering a degraded session. ## Non-goals - Any write, create, update, or delete operation against either cloud. Read-only is absolute. - Clouds beyond GCP and AWS. The provider interface should not foreclose a third, but none ships here. -- Reading secrets, downloading bucket objects, shelling into instances, or impersonating identities of the agent's choosing. These sit on a hardcoded deny floor regardless of config. +- Reading secrets, downloading bucket objects, shelling into instances, or acting as any identity or target the deployment did not configure. The dangerous operations sit on a hardcoded deny floor regardless of config; the agent's target selection is bounded to the profile-pinned set. - OAuth / SSO browser login flows inside triagent. Base authentication is the operator's own (or the workload's); triagent never runs an interactive login. This is a candidate future enhancement, not v1. - Billing, cost, or quota reporting. ## Design overview -One package, `pkg/mcp/cloud/`, exposing `New(Options)` + `Run(ctx)` + a sibling `specs.go::ToolSpecs()`, registered with one `case "cloud"` in `cmd/triagent-mcp/serve.go` (ADR-0001) and selected at launch by `--provider=gcp|aws`. This mirrors the git MCP, which is one package bound per-repo via `--repo` and aliased `triagent-git-` at the `mcpconfig.go` wiring layer (`internal/preflight/mcpconfig.go`, ADR-0003); here the bound target is a cloud provider, aliased `triagent-cloud-`. Deployment config (provider, pinned identity, scope allowlist, command-allowlist override path) loads from the runtime profile (ADR-0008). +One package, `pkg/mcp/cloud/`, exposing `New(Options)` + `Run(ctx)` + a sibling `specs.go::ToolSpecs()`, registered with one `case "cloud"` in `cmd/triagent-mcp/serve.go` (ADR-0001) and selected at launch by `--provider=gcp|aws`. This mirrors the git MCP, which is one package bound per-repo via `--repo` and aliased `triagent-git-` at the `mcpconfig.go` wiring layer (`internal/preflight/mcpconfig.go`, ADR-0003); here the bound target is a cloud provider, aliased `triagent-cloud-`. Deployment config (provider, the pinned identities and selectable targets, scope allowlist, command-allowlist override path) loads from the runtime profile (ADR-0008). The tool surface is provider-agnostic and lives once in `specs.go`. It is deliberately thin: two typed tools where shaped output clearly pays its context cost, plus a gated CLI escape hatch for the long tail. - `list_inventory` — projects / accounts and the accessible resources within an allowlisted scope, so the agent can orient. -- `session_status` — the read-only whoami: which pinned identity is active and whether it is valid. +- `session_status` — the read-only whoami: which pinned identity is active, in which target, and whether it is valid. +- `set_active_target` — choose which project (GCP) or account (AWS) subsequent `run_cli` commands run against, from the deployment-pinned set surfaced by `list_inventory`. The MCP applies the choice as a controlled environment variable; the agent never names an arbitrary target. - `run_cli` — a gated, read-only `gcloud` / `aws` invocation for everything else, with argument tokens supplied as an array. - `list_allowed_commands` — the discovery tool that reads the same gating config `run_cli` enforces, so what is advertised is exactly what is permitted. @@ -39,7 +40,7 @@ Each typed tool calls through a `Provider` interface; selecting `--provider` cho ```mermaid flowchart TD - operator[operator agent] --> typed["typed tools
list_inventory · session_status"] + operator[operator agent] --> typed["typed tools
list_inventory · session_status · set_active_target"] operator --> disc["list_allowed_commands"] operator --> cli["run_cli
(argv tokens only)"] typed --> iface{{Provider interface}} @@ -49,13 +50,13 @@ flowchart TD harness --> iface iface --> gcp["gcp provider
gcloud + defaults"] iface --> aws["aws provider
aws + defaults"] - id[("pinned read-only identity
impersonated via harness env")] -.outer floor.-> gcp + id[("pinned read-only identity set
active target via harness env")] -.outer floor.-> gcp id -.outer floor.-> aws ``` ## Security model -The security model is the heart of this feature. It has two independent layers: the agent cannot run a forbidden command, and the agent cannot act as a forbidden identity. +The security model is the heart of this feature. It has two independent layers: the agent cannot run a forbidden command, and the agent cannot act outside the deployment-pinned set of identities and targets — it may select among them, never beyond. ### The command harness cannot be bypassed @@ -65,21 +66,27 @@ The security model is the heart of this feature. It has two independent layers: - **Direct `execve`, no shell.** The harness execs the provider's fixed binary with the argv array (`exec.CommandContext`). No `sh -c` exists anywhere in the package. Shell metacharacters (`|`, `;`, `&&`, `$(…)`, backticks, `>`, newlines) have meaning only to a shell; handed to `gcloud`/`aws` as literal argv tokens they are inert and rejected by the binary. A unit test asserts no `sh -c` / `bash -c` construction exists and that an argv full of metacharacters never spawns a second process. - **Positive allowlist on the normalized subcommand path** (for example `compute firewall-rules list`, `projects list`), loaded from an embedded default JSON overridable via a profile-pointed path. This is the `LoadAllowlist` pattern from `pkg/mcp/k8s/allowlist.go`: embedded default, optional override, applied identically. - **A hardcoded deny floor the config can never re-enable**, mirroring how `LoadAllowlist` always filters `Secret` regardless of the kinds config. The floor covers dangerous subcommands (`secrets ... access`, `ssh`/`scp`, `cp`/`sync`, `auth`, `config set`) and dangerous flags (`--impersonate-service-account`, `--account`, `--profile`, `--endpoint-url`, `--cli-input-*`, `--configuration`), plus argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). -- **Scope validation.** Any `--project` and region/zone (`--region` / `--zone`) in the argv must be in the profile's scope allowlist, so the agent cannot pivot to an un-allowlisted target. Account selection is not scope-validated on argv: `--account` and `--profile` are deny-floored, and account reach is constrained by the pinned identity (`ScopeAllowlist.Accounts` is informational — the AWS account an agent can touch is bounded by the assume-role profile's `role_arn`, not by an argv flag). +- **Target selection and scope validation.** The active project (GCP) or account (AWS) is chosen via `set_active_target` from the deployment-pinned set and applied by the MCP through a controlled env var (see below), not supplied by the agent: `--project`, `--account`, `--profile`, and `--impersonate-service-account` are deny-floored, so the agent cannot override the pin. Because the active target is a pinned in-scope value rather than an ambient default, a command that omits the flag still runs against an allowlisted target. Region/zone (`--region` / `--zone`) in the argv must be in the profile's scope allowlist. - **Output truncation** keeps a raw response from blowing the context budget. - **Pinned binary and minimal env.** The binary is resolved to an absolute path once at startup; the subprocess runs with an explicit minimal `cmd.Env` (so a poisoned `PATH` cannot substitute a different binary) and closed stdin (no interactive prompt or fed input). -### The agent cannot select or escalate identity +### The agent selects a target within a pinned set, never beyond it -The cloud identity is a deployment-chosen, read-only principal pinned in the profile. The agent can read which identity is active (`session_status`, `list_allowed_commands`) but has no tool to choose, change, or authenticate one. +Cloud access is a deployment-chosen set of read-only identities and targets pinned in the profile. The agent reads which target is active (`session_status`) and switches among the configured ones (`set_active_target`), but it cannot name a target the deployment did not authorize, and it cannot escalate privilege within the set. The selectable set is the profile's `scope.projects` for GCP (or, when that axis is unconstrained, the projects `list_inventory` surfaces) and the source's configured `accounts` list for AWS; a `set_active_target` outside the set is rejected. -The identity is a stable contract; how the harness acquires credentials for it is a swappable realization, set by the deployment and injected through `cmd.Env` (which the agent never controls — it supplies argv only): +How the active target is applied is the rule that keeps it bounded and leak-free: -- **Operator-ambient base auth plus harness-pinned impersonation (v1 primary).** The operator is authenticated as themselves through their own normal tooling (`gcloud auth login`, `aws sso login`). The harness pins impersonation of the configured read-only identity via environment: `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=` for GCP; `AWS_PROFILE=` (a profile whose `role_arn` is the read-only role with the operator's base as `source_profile`) for AWS. triagent stores no credential. Because the pin is in env, not argv, `--impersonate-service-account` and `--profile` stay on the agent deny floor without contradiction. Re-authentication is the operator's own corporate flow, outside triagent. -- **Workload Identity / IRSA (server / headless).** The workload is the pinned identity; base credentials come from the metadata server. This falls out of the same env-injection code path with the base credential sourced from the environment instead of the operator. triagent stores no credential. -- **Static read-only key connection (deferred fallback).** A service-account key (GCP) or static access keys (AWS) pasted into the connections panel, for environments where assume-role is not granted. This is the only realization where triagent holds a secret; it is out of v1 scope and slots in later behind the same connection surface and env injection. +- **One MCP-controlled env var, built fresh into each `run_cli` child process** — `CLOUDSDK_CORE_PROJECT` for GCP, `AWS_PROFILE` for AWS — and **never** a process-global `os.Setenv`, so there is no cross-request or cross-session bleed (the same discipline the identity probe follows). The values are non-secret identifiers (a project id, a profile name), not credentials. The agent supplies argv only; `--project`, `--account`, `--profile`, and `--impersonate-service-account` stay on the deny floor, so it cannot override the pin. Because the pin is the active target rather than an ambient default, a command that omits the flag still runs in-scope. +- **GCP — one identity, many projects.** A single impersonated read-only service account spans the allowlisted projects. Switching the active target changes only `CLOUDSDK_CORE_PROJECT`; the identity is unchanged. `session_status` reports the same service account throughout, with the active project alongside. +- **AWS — one account per role.** An IAM role lives in one account, so each configured account is its own read-only role. The AWS source lists `accounts: [{account_id, role_arn}]`; triagent generates a per-account `~/.aws/config` profile at startup (each `role_arn` layered over the operator's SSO `source_profile`), and switching sets `AWS_PROFILE` to the active account's profile. The identity therefore changes per account, and `session_status` re-probes the active role on switch. triagent still stores no credential — it sets a profile name and the AWS CLI performs the assume-role from the operator's base. -The deployment's read-only IAM grant on the pinned identity is the outermost floor: even a misconfigured-too-broad command allowlist cannot read secrets or exfiltrate, because the identity itself lacks the permission. +How the harness acquires the base credentials for an identity is a swappable realization, set by the deployment and injected through `cmd.Env` (which the agent never controls): + +- **Operator-ambient base auth plus harness-pinned impersonation/assume-role (v1 primary).** The operator authenticates as themselves through their own tooling (`gcloud auth login`, `aws sso login`); the harness pins the configured read-only identity via the env vars above. triagent stores no credential. Re-authentication is the operator's own corporate flow, outside triagent. +- **Workload Identity / IRSA (server / headless).** The workload is the pinned identity; base credentials come from the metadata server, through the same env-injection path. triagent stores no credential. +- **Static read-only key connection (deferred fallback).** A service-account key (GCP) or static access keys (AWS) for environments where assume-role is not granted. This is the only realization where triagent would hold a secret; it is out of v1 scope. + +The deployment's read-only IAM grant on each pinned identity is the outermost floor: even a misconfigured-too-broad command allowlist cannot read secrets or exfiltrate, because the identity itself lacks the permission. ## Auth readiness, preflight, and visible degrade @@ -93,7 +100,7 @@ A single whoami probe validates the identity chain: base credentials valid, impe - **The agent bypasses the command safety net** (shell metacharacters, flag escapes, identity/endpoint redirection, scope pivot). Mitigated by structural defenses, not string filtering: no shell ever (argv + direct `execve`); a deny floor covering subcommands, flags, and argument prefixes; scope validation. The read-only IAM grant is an independent backstop. - **Advertised commands drift from enforced commands.** `list_allowed_commands` and `run_cli` read one config; the allowlist is the single source of truth. -- **The agent widens its own allowlist or picks its identity.** The config and the pinned identity load server-side from the profile; the agent has tools to read them, none to mutate them. Impersonation is pinned in harness-controlled env, never agent argv. +- **The agent widens its own allowlist, picks an unauthorized target, or escalates.** The config and the pinned set load server-side from the profile; the agent can read them and select among the configured targets (`set_active_target`), but has no tool to mutate them or name a target outside the set. The active target is applied through harness-controlled env, never agent argv, and identity/target flags stay deny-floored. - **Raw CLI output blows the context budget.** Output truncation on the escape hatch, plus typed tools for the orientation path. - **Operator-ambient impersonation needs an IAM grant** (assume-role / `serviceAccountTokenCreator` on the pinned role). This is a one-time admin setup and the price of not storing a secret; documented as a deployment prerequisite. Workload Identity is the no-grant alternative for server deployments. - **Soft-degrade is new preflight behavior.** The degrade path is cloud-source-scoped and explicit; the existing k8s block-on-failure behavior is unchanged. @@ -106,6 +113,7 @@ A single whoami probe validates the identity chain: base credentials valid, impe - **Read-only enforced solely by IAM, free-form CLI on top.** Rejected as the whole story: read-only IAM still reads secrets and exfiltrates bucket objects, so "read-only" is necessary but not sufficient. The harness deny floor is what excludes those; IAM is the backstop, not the fence. - **triagent holds a stored cloud credential as the primary model** (static key connection). Rejected as v1 primary: it puts a long-lived secret in triagent's custody and forces in-app re-auth. Operator-ambient impersonation stores nothing, gives a better audit trail (human plus role), and pushes re-auth to the operator's existing tooling. The stored-key connection survives as a deferred fallback for environments without assume-role. - **OAuth / SSO login inside triagent.** Deferred: a different tier of work (callback handling, refresh-token storage and rotation, per-provider divergence, expiry visibility) for marginal gain over piggybacking on the operator's own session. Slots in later as one more env source behind the same connection. +- **Runtime `AssumeRole` brokering for AWS multi-account.** Rejected: having the MCP call `sts:AssumeRole` and hold the returned temporary credentials would let a deployment skip per-account profiles, but it reverses the no-stored-credential invariant (triagent would hold and refresh live credentials) and diverges from GCP. Instead triagent generates per-account `~/.aws/config` profiles from the configured `accounts` list and switches `AWS_PROFILE`, so the AWS CLI performs the assume-role and triagent holds nothing. - **Block the session on cloud auth failure** (mirroring k8s preflight). Rejected: cloud is secondary context; a stale cloud credential must not make a Kubernetes incident un-investigable. Visible degrade keeps triage moving. ## Vocabulary @@ -114,4 +122,5 @@ A single whoami probe validates the identity chain: base credentials valid, impe - The swappable backend is a **provider** (`gcp`, `aws`) behind the **`Provider` interface**. - The gated escape hatch is **`run_cli`**; its catalog is **`list_allowed_commands`**. - The deployment-chosen identity is the **pinned identity**; the ways the harness acquires credentials for it are **realizations** (operator-ambient impersonation, Workload Identity, static-key connection). +- The project (GCP) or account (AWS) the agent currently operates in is the **active target**, chosen from the pinned set via **`set_active_target`** and applied through the MCP's controlled env, never agent argv. - The investigative groupings (inventory, reachability, permissions, cluster, logs, audit) are **axes** — a navigational vocabulary for organizing coverage, never a code identifier. From b6c4c5aaf709beef25f0e4b8cf59ae91b27fdf1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 04:16:28 +0200 Subject: [PATCH 31/76] docs(spec): keep the base cloud-context ADR as-built; switching design gets its own spec Reverts the set_active_target amendment to 2026-05-30-cloud-context-mcp-design.md. That doc is the durable ADR for the shipped base MCP; folding an unbuilt feature into it blurs what exists vs what is new. The active-target-selection design moves to its own ADR that references this one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-cloud-context-mcp-design.md | 41 ++++++++----------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md index 975fc3fd..39aced7d 100644 --- a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md +++ b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md @@ -13,26 +13,25 @@ This spec defines a single read-only cloud-context MCP that gives the agent that - Let the operator agent answer cloud-context questions (reachability, permissions, cluster setup, logs, audit trail, inventory) for GCP and AWS from inside an investigation, without a human leaving the loop. - Make adding coverage a config edit, not new Go; make adding a cloud a new provider behind one interface, not a parallel MCP. - Guarantee read-only by construction and by harness, with a safety boundary the agent provably cannot bypass. -- Pin cloud access to a deployment-chosen set of read-only identities and targets. The agent may select which configured target it operates in (a project for GCP, an account for AWS), but can neither reach outside that set nor escalate privilege within it. +- Pin the cloud identity to a deployment-chosen, read-only principal that the agent can neither select nor escalate. - Surface cloud auth readiness before a session starts, so the operator fixes a stale credential proactively rather than discovering a degraded session. ## Non-goals - Any write, create, update, or delete operation against either cloud. Read-only is absolute. - Clouds beyond GCP and AWS. The provider interface should not foreclose a third, but none ships here. -- Reading secrets, downloading bucket objects, shelling into instances, or acting as any identity or target the deployment did not configure. The dangerous operations sit on a hardcoded deny floor regardless of config; the agent's target selection is bounded to the profile-pinned set. +- Reading secrets, downloading bucket objects, shelling into instances, or impersonating identities of the agent's choosing. These sit on a hardcoded deny floor regardless of config. - OAuth / SSO browser login flows inside triagent. Base authentication is the operator's own (or the workload's); triagent never runs an interactive login. This is a candidate future enhancement, not v1. - Billing, cost, or quota reporting. ## Design overview -One package, `pkg/mcp/cloud/`, exposing `New(Options)` + `Run(ctx)` + a sibling `specs.go::ToolSpecs()`, registered with one `case "cloud"` in `cmd/triagent-mcp/serve.go` (ADR-0001) and selected at launch by `--provider=gcp|aws`. This mirrors the git MCP, which is one package bound per-repo via `--repo` and aliased `triagent-git-` at the `mcpconfig.go` wiring layer (`internal/preflight/mcpconfig.go`, ADR-0003); here the bound target is a cloud provider, aliased `triagent-cloud-`. Deployment config (provider, the pinned identities and selectable targets, scope allowlist, command-allowlist override path) loads from the runtime profile (ADR-0008). +One package, `pkg/mcp/cloud/`, exposing `New(Options)` + `Run(ctx)` + a sibling `specs.go::ToolSpecs()`, registered with one `case "cloud"` in `cmd/triagent-mcp/serve.go` (ADR-0001) and selected at launch by `--provider=gcp|aws`. This mirrors the git MCP, which is one package bound per-repo via `--repo` and aliased `triagent-git-` at the `mcpconfig.go` wiring layer (`internal/preflight/mcpconfig.go`, ADR-0003); here the bound target is a cloud provider, aliased `triagent-cloud-`. Deployment config (provider, pinned identity, scope allowlist, command-allowlist override path) loads from the runtime profile (ADR-0008). The tool surface is provider-agnostic and lives once in `specs.go`. It is deliberately thin: two typed tools where shaped output clearly pays its context cost, plus a gated CLI escape hatch for the long tail. - `list_inventory` — projects / accounts and the accessible resources within an allowlisted scope, so the agent can orient. -- `session_status` — the read-only whoami: which pinned identity is active, in which target, and whether it is valid. -- `set_active_target` — choose which project (GCP) or account (AWS) subsequent `run_cli` commands run against, from the deployment-pinned set surfaced by `list_inventory`. The MCP applies the choice as a controlled environment variable; the agent never names an arbitrary target. +- `session_status` — the read-only whoami: which pinned identity is active and whether it is valid. - `run_cli` — a gated, read-only `gcloud` / `aws` invocation for everything else, with argument tokens supplied as an array. - `list_allowed_commands` — the discovery tool that reads the same gating config `run_cli` enforces, so what is advertised is exactly what is permitted. @@ -40,7 +39,7 @@ Each typed tool calls through a `Provider` interface; selecting `--provider` cho ```mermaid flowchart TD - operator[operator agent] --> typed["typed tools
list_inventory · session_status · set_active_target"] + operator[operator agent] --> typed["typed tools
list_inventory · session_status"] operator --> disc["list_allowed_commands"] operator --> cli["run_cli
(argv tokens only)"] typed --> iface{{Provider interface}} @@ -50,13 +49,13 @@ flowchart TD harness --> iface iface --> gcp["gcp provider
gcloud + defaults"] iface --> aws["aws provider
aws + defaults"] - id[("pinned read-only identity set
active target via harness env")] -.outer floor.-> gcp + id[("pinned read-only identity
impersonated via harness env")] -.outer floor.-> gcp id -.outer floor.-> aws ``` ## Security model -The security model is the heart of this feature. It has two independent layers: the agent cannot run a forbidden command, and the agent cannot act outside the deployment-pinned set of identities and targets — it may select among them, never beyond. +The security model is the heart of this feature. It has two independent layers: the agent cannot run a forbidden command, and the agent cannot act as a forbidden identity. ### The command harness cannot be bypassed @@ -66,27 +65,21 @@ The security model is the heart of this feature. It has two independent layers: - **Direct `execve`, no shell.** The harness execs the provider's fixed binary with the argv array (`exec.CommandContext`). No `sh -c` exists anywhere in the package. Shell metacharacters (`|`, `;`, `&&`, `$(…)`, backticks, `>`, newlines) have meaning only to a shell; handed to `gcloud`/`aws` as literal argv tokens they are inert and rejected by the binary. A unit test asserts no `sh -c` / `bash -c` construction exists and that an argv full of metacharacters never spawns a second process. - **Positive allowlist on the normalized subcommand path** (for example `compute firewall-rules list`, `projects list`), loaded from an embedded default JSON overridable via a profile-pointed path. This is the `LoadAllowlist` pattern from `pkg/mcp/k8s/allowlist.go`: embedded default, optional override, applied identically. - **A hardcoded deny floor the config can never re-enable**, mirroring how `LoadAllowlist` always filters `Secret` regardless of the kinds config. The floor covers dangerous subcommands (`secrets ... access`, `ssh`/`scp`, `cp`/`sync`, `auth`, `config set`) and dangerous flags (`--impersonate-service-account`, `--account`, `--profile`, `--endpoint-url`, `--cli-input-*`, `--configuration`), plus argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). -- **Target selection and scope validation.** The active project (GCP) or account (AWS) is chosen via `set_active_target` from the deployment-pinned set and applied by the MCP through a controlled env var (see below), not supplied by the agent: `--project`, `--account`, `--profile`, and `--impersonate-service-account` are deny-floored, so the agent cannot override the pin. Because the active target is a pinned in-scope value rather than an ambient default, a command that omits the flag still runs against an allowlisted target. Region/zone (`--region` / `--zone`) in the argv must be in the profile's scope allowlist. +- **Scope validation.** Any `--project` and region/zone (`--region` / `--zone`) in the argv must be in the profile's scope allowlist, so the agent cannot pivot to an un-allowlisted target. Account selection is not scope-validated on argv: `--account` and `--profile` are deny-floored, and account reach is constrained by the pinned identity (`ScopeAllowlist.Accounts` is informational — the AWS account an agent can touch is bounded by the assume-role profile's `role_arn`, not by an argv flag). - **Output truncation** keeps a raw response from blowing the context budget. - **Pinned binary and minimal env.** The binary is resolved to an absolute path once at startup; the subprocess runs with an explicit minimal `cmd.Env` (so a poisoned `PATH` cannot substitute a different binary) and closed stdin (no interactive prompt or fed input). -### The agent selects a target within a pinned set, never beyond it +### The agent cannot select or escalate identity -Cloud access is a deployment-chosen set of read-only identities and targets pinned in the profile. The agent reads which target is active (`session_status`) and switches among the configured ones (`set_active_target`), but it cannot name a target the deployment did not authorize, and it cannot escalate privilege within the set. The selectable set is the profile's `scope.projects` for GCP (or, when that axis is unconstrained, the projects `list_inventory` surfaces) and the source's configured `accounts` list for AWS; a `set_active_target` outside the set is rejected. +The cloud identity is a deployment-chosen, read-only principal pinned in the profile. The agent can read which identity is active (`session_status`, `list_allowed_commands`) but has no tool to choose, change, or authenticate one. -How the active target is applied is the rule that keeps it bounded and leak-free: +The identity is a stable contract; how the harness acquires credentials for it is a swappable realization, set by the deployment and injected through `cmd.Env` (which the agent never controls — it supplies argv only): -- **One MCP-controlled env var, built fresh into each `run_cli` child process** — `CLOUDSDK_CORE_PROJECT` for GCP, `AWS_PROFILE` for AWS — and **never** a process-global `os.Setenv`, so there is no cross-request or cross-session bleed (the same discipline the identity probe follows). The values are non-secret identifiers (a project id, a profile name), not credentials. The agent supplies argv only; `--project`, `--account`, `--profile`, and `--impersonate-service-account` stay on the deny floor, so it cannot override the pin. Because the pin is the active target rather than an ambient default, a command that omits the flag still runs in-scope. -- **GCP — one identity, many projects.** A single impersonated read-only service account spans the allowlisted projects. Switching the active target changes only `CLOUDSDK_CORE_PROJECT`; the identity is unchanged. `session_status` reports the same service account throughout, with the active project alongside. -- **AWS — one account per role.** An IAM role lives in one account, so each configured account is its own read-only role. The AWS source lists `accounts: [{account_id, role_arn}]`; triagent generates a per-account `~/.aws/config` profile at startup (each `role_arn` layered over the operator's SSO `source_profile`), and switching sets `AWS_PROFILE` to the active account's profile. The identity therefore changes per account, and `session_status` re-probes the active role on switch. triagent still stores no credential — it sets a profile name and the AWS CLI performs the assume-role from the operator's base. +- **Operator-ambient base auth plus harness-pinned impersonation (v1 primary).** The operator is authenticated as themselves through their own normal tooling (`gcloud auth login`, `aws sso login`). The harness pins impersonation of the configured read-only identity via environment: `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=` for GCP; `AWS_PROFILE=` (a profile whose `role_arn` is the read-only role with the operator's base as `source_profile`) for AWS. triagent stores no credential. Because the pin is in env, not argv, `--impersonate-service-account` and `--profile` stay on the agent deny floor without contradiction. Re-authentication is the operator's own corporate flow, outside triagent. +- **Workload Identity / IRSA (server / headless).** The workload is the pinned identity; base credentials come from the metadata server. This falls out of the same env-injection code path with the base credential sourced from the environment instead of the operator. triagent stores no credential. +- **Static read-only key connection (deferred fallback).** A service-account key (GCP) or static access keys (AWS) pasted into the connections panel, for environments where assume-role is not granted. This is the only realization where triagent holds a secret; it is out of v1 scope and slots in later behind the same connection surface and env injection. -How the harness acquires the base credentials for an identity is a swappable realization, set by the deployment and injected through `cmd.Env` (which the agent never controls): - -- **Operator-ambient base auth plus harness-pinned impersonation/assume-role (v1 primary).** The operator authenticates as themselves through their own tooling (`gcloud auth login`, `aws sso login`); the harness pins the configured read-only identity via the env vars above. triagent stores no credential. Re-authentication is the operator's own corporate flow, outside triagent. -- **Workload Identity / IRSA (server / headless).** The workload is the pinned identity; base credentials come from the metadata server, through the same env-injection path. triagent stores no credential. -- **Static read-only key connection (deferred fallback).** A service-account key (GCP) or static access keys (AWS) for environments where assume-role is not granted. This is the only realization where triagent would hold a secret; it is out of v1 scope. - -The deployment's read-only IAM grant on each pinned identity is the outermost floor: even a misconfigured-too-broad command allowlist cannot read secrets or exfiltrate, because the identity itself lacks the permission. +The deployment's read-only IAM grant on the pinned identity is the outermost floor: even a misconfigured-too-broad command allowlist cannot read secrets or exfiltrate, because the identity itself lacks the permission. ## Auth readiness, preflight, and visible degrade @@ -100,7 +93,7 @@ A single whoami probe validates the identity chain: base credentials valid, impe - **The agent bypasses the command safety net** (shell metacharacters, flag escapes, identity/endpoint redirection, scope pivot). Mitigated by structural defenses, not string filtering: no shell ever (argv + direct `execve`); a deny floor covering subcommands, flags, and argument prefixes; scope validation. The read-only IAM grant is an independent backstop. - **Advertised commands drift from enforced commands.** `list_allowed_commands` and `run_cli` read one config; the allowlist is the single source of truth. -- **The agent widens its own allowlist, picks an unauthorized target, or escalates.** The config and the pinned set load server-side from the profile; the agent can read them and select among the configured targets (`set_active_target`), but has no tool to mutate them or name a target outside the set. The active target is applied through harness-controlled env, never agent argv, and identity/target flags stay deny-floored. +- **The agent widens its own allowlist or picks its identity.** The config and the pinned identity load server-side from the profile; the agent has tools to read them, none to mutate them. Impersonation is pinned in harness-controlled env, never agent argv. - **Raw CLI output blows the context budget.** Output truncation on the escape hatch, plus typed tools for the orientation path. - **Operator-ambient impersonation needs an IAM grant** (assume-role / `serviceAccountTokenCreator` on the pinned role). This is a one-time admin setup and the price of not storing a secret; documented as a deployment prerequisite. Workload Identity is the no-grant alternative for server deployments. - **Soft-degrade is new preflight behavior.** The degrade path is cloud-source-scoped and explicit; the existing k8s block-on-failure behavior is unchanged. @@ -113,7 +106,6 @@ A single whoami probe validates the identity chain: base credentials valid, impe - **Read-only enforced solely by IAM, free-form CLI on top.** Rejected as the whole story: read-only IAM still reads secrets and exfiltrates bucket objects, so "read-only" is necessary but not sufficient. The harness deny floor is what excludes those; IAM is the backstop, not the fence. - **triagent holds a stored cloud credential as the primary model** (static key connection). Rejected as v1 primary: it puts a long-lived secret in triagent's custody and forces in-app re-auth. Operator-ambient impersonation stores nothing, gives a better audit trail (human plus role), and pushes re-auth to the operator's existing tooling. The stored-key connection survives as a deferred fallback for environments without assume-role. - **OAuth / SSO login inside triagent.** Deferred: a different tier of work (callback handling, refresh-token storage and rotation, per-provider divergence, expiry visibility) for marginal gain over piggybacking on the operator's own session. Slots in later as one more env source behind the same connection. -- **Runtime `AssumeRole` brokering for AWS multi-account.** Rejected: having the MCP call `sts:AssumeRole` and hold the returned temporary credentials would let a deployment skip per-account profiles, but it reverses the no-stored-credential invariant (triagent would hold and refresh live credentials) and diverges from GCP. Instead triagent generates per-account `~/.aws/config` profiles from the configured `accounts` list and switches `AWS_PROFILE`, so the AWS CLI performs the assume-role and triagent holds nothing. - **Block the session on cloud auth failure** (mirroring k8s preflight). Rejected: cloud is secondary context; a stale cloud credential must not make a Kubernetes incident un-investigable. Visible degrade keeps triage moving. ## Vocabulary @@ -122,5 +114,4 @@ A single whoami probe validates the identity chain: base credentials valid, impe - The swappable backend is a **provider** (`gcp`, `aws`) behind the **`Provider` interface**. - The gated escape hatch is **`run_cli`**; its catalog is **`list_allowed_commands`**. - The deployment-chosen identity is the **pinned identity**; the ways the harness acquires credentials for it are **realizations** (operator-ambient impersonation, Workload Identity, static-key connection). -- The project (GCP) or account (AWS) the agent currently operates in is the **active target**, chosen from the pinned set via **`set_active_target`** and applied through the MCP's controlled env, never agent argv. - The investigative groupings (inventory, reachability, permissions, cluster, logs, audit) are **axes** — a navigational vocabulary for organizing coverage, never a code identifier. From 267a7528c5f45811cc35fffa15e905302fec2a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 04:17:49 +0200 Subject: [PATCH 32/76] docs(spec): cloud active-target-selection ADR (extends the cloud-context MCP) A separate ADR for the new capability: a set_active_target tool letting the agent choose a project (GCP) / account (AWS) from a deployment-pinned set, applied as an MCP-controlled per-exec env var, with AWS multi-account via generated profiles. References the base cloud-context spec, which ships unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...31-cloud-active-target-selection-design.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md diff --git a/docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md b/docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md new file mode 100644 index 00000000..f50278c5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md @@ -0,0 +1,76 @@ +# Cloud active-target selection (extends the cloud-context MCP) + +This design extends the read-only cloud-context MCP defined in [2026-05-30-cloud-context-mcp-design.md](2026-05-30-cloud-context-mcp-design.md). That base MCP (the `Provider` interface, the no-shell harness, the command allowlist and deny floor, the identity probe, and the GCP/AWS providers) ships unchanged. This document specifies only the addition: letting the agent operate across more than one project or account, by selecting an active target from a deployment-pinned set. + +## Problem + +The base MCP pins one target per cloud source and the agent cannot change it. That fits GCP, where a single impersonated service account can be granted read-only on many projects, so one identity already spans them. It does not fit AWS: an IAM role lives in exactly one account, so a single assumed role can only read one account's resources. A responder investigating an incident that crosses accounts (or that lives in a different account than the one the source happens to pin) has no way to follow the thread without a second source and a human switch. + +Two consequences in the base MCP make this concrete. The AWS `list_inventory` runs `organizations list-accounts` and can advertise the whole org, yet `run_cli` only works in the role's own account, so inventory over-promises reachability. And scope only constrains an explicit `--project`/`--region`; omitting the flag falls back to the CLI's ambient default, which scope does not police. + +## Goals + +- Let the agent operate across the deployment's set of projects (GCP) and accounts (AWS) within one cloud source, by selecting which target subsequent `run_cli` commands run against. +- Keep the selection bounded: the agent may choose only among targets the deployment configured, and can neither name an arbitrary target nor escalate within the set. +- Make the active target the effective default, so a command that omits the target flag runs against an in-scope target rather than an ambient one. This closes the scope-by-omission gap. +- Hold no credential. The mechanism stays env injection, the same as the base MCP's pinned identity. + +## Non-goals + +- Runtime credential brokering. triagent does not call `sts:AssumeRole` itself or hold temporary credentials (see Alternatives). +- Region switching. Region/zone stays a scope-validated explicit argv flag as in the base MCP; this feature is about project/account only. +- Clouds beyond GCP and AWS, and any write path. Unchanged from the base MCP. +- Choosing a target the deployment did not configure. The selectable set is the profile's, not the agent's. + +## Design + +### The `set_active_target` tool + +One tool is added to the provider-agnostic surface in `specs.go`: + +- `set_active_target` — input `target`: an ID from `list_inventory` (a project id for GCP, an account id for AWS). It sets the session's active target and returns the new target's `session_status` so the agent immediately sees whether it is valid. A `target` outside the pinned set is rejected before anything changes. + +`session_status` is extended to report the active target alongside the identity. `list_inventory` already surfaces the selectable set as its `Scopes`. + +### The selectable set + +- **GCP**: the profile's `scope.projects`. When that axis is unconstrained (empty), the set is the projects `list_inventory` surfaces (those the impersonated service account can see). +- **AWS**: a new per-source `accounts` list, each entry `{account_id, role_arn}`. The single-account deployment is a one-entry list, which reproduces today's behavior. + +### Applying the selection (the leak-safe rule) + +The active target is in-memory state on the cloud MCP `Server`, applied through one MCP-controlled environment variable built fresh into each `run_cli` child process, and **never** through a process-global `os.Setenv` (the same discipline the identity probe already follows, so there is no cross-request or cross-session bleed): + +- **GCP**: `CLOUDSDK_CORE_PROJECT=`. gcloud uses it as the default project for every command; commands that take no project ignore it. +- **AWS**: `AWS_PROFILE=`. + +The values are non-secret identifiers (a project id, a profile name), not credentials. `--project`, `--account`, `--profile`, and `--impersonate-service-account` stay on the deny floor, so the agent cannot override the MCP's pin; `set_active_target` is the only way to change the target. + +### GCP versus AWS, by mechanism + +- **GCP — one identity, many projects.** A single impersonated read-only service account spans the allowlisted projects. Switching changes only `CLOUDSDK_CORE_PROJECT`; the identity is unchanged. `session_status` reports the same service account throughout, with the active project alongside. +- **AWS — one account per role.** Each configured account is its own read-only role. triagent generates a `~/.aws/config` profile per `accounts` entry at startup, each layering the entry's `role_arn` over the operator's SSO `source_profile`; switching sets `AWS_PROFILE` to the active account's generated profile. The identity changes per account, so `session_status` re-probes the active role on switch. triagent still stores no credential: it sets a profile name and the AWS CLI performs the assume-role from the operator's base. + +### Default active target + +If the set has exactly one entry, it is the active target from session start (today's behavior). If it has several, `run_cli` returns an actionable error until the agent calls `set_active_target`, so a command can never run against an unintended default. + +## Security model + +This preserves the base MCP's two-layer model and tightens one part of it. The agent still cannot run a forbidden command (the harness, allowlist, and deny floor are unchanged). The identity layer changes from "the agent cannot select the target" to "the agent selects within a deployment-pinned set, never beyond it": the selectable set loads server-side from the profile, the agent has a tool to choose among its members but none to mutate it or add to it, and the target-selecting flags remain deny-floored so the choice can only be applied through the MCP's controlled env. The read-only IAM grant on each pinned identity remains the outermost floor. + +The scope-by-omission gap the base MCP documents is closed here: because the active target is an MCP-pinned in-scope value rather than the CLI's ambient default, a command that omits the flag still runs against an allowlisted target. + +## Builds on (unchanged) + +The harness, command allowlist, deny floor, identity probe, output truncation, provider interface, and the visible-degrade preflight from the base MCP are unchanged. This feature adds the `set_active_target` tool, the AWS `accounts` config plus its startup profile generation, the active-target session state, and the per-exec env application. The AWS `list_inventory` is adjusted so it reflects the reachable target set rather than over-advertising the whole organization. + +## Alternatives considered + +- **Runtime `AssumeRole` brokering for AWS.** The MCP would call `sts:AssumeRole` on switch and use the returned temporary credentials for `run_cli`. Rejected: it removes the per-account profile setup but reverses the base MCP's defining invariant (triagent would hold and refresh live credentials), enlarges the leak surface, and diverges from GCP. Generating per-account profiles and switching `AWS_PROFILE` gives the same ergonomics with no credential in triagent's custody. +- **Injecting the GCP `--project` flag.** The MCP would append `--project=` to each `run_cli`. Rejected for asymmetry and fragility: it needs per-command knowledge of which commands accept `--project`, where the env var (`CLOUDSDK_CORE_PROJECT`) applies uniformly and matches the AWS shape. + +## Vocabulary + +- The project (GCP) or account (AWS) the agent currently operates in is the **active target**, chosen from the deployment-pinned set via **`set_active_target`** and applied through the MCP's controlled env, never agent argv. +- The base MCP's terms (pinned identity, realizations, provider, axes, the gated `run_cli`) carry over unchanged. From 6ab507209ef2ef156e7dbd7fce234518117e5677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 04:39:33 +0200 Subject: [PATCH 33/76] docs(plan): implementation plan for cloud active-target selection PR A (provider-agnostic core: Target, the two Provider methods, server active-target state + env apply, set_active_target tool, run_cli gating, session_status) and PR B (gcp/aws impls, aws accounts config + generated profiles, inventory honesty, launcher wiring, docs). Folds into #53. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-05-31-cloud-active-target-selection.md | 484 ++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-cloud-active-target-selection.md diff --git a/docs/superpowers/plans/2026-05-31-cloud-active-target-selection.md b/docs/superpowers/plans/2026-05-31-cloud-active-target-selection.md new file mode 100644 index 00000000..bbb31c2b --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-cloud-active-target-selection.md @@ -0,0 +1,484 @@ +# Cloud active-target selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `set_active_target` tool that lets the operator agent choose which project (GCP) or account (AWS) subsequent `run_cli` commands run against, from a deployment-pinned set, applied as an MCP-controlled per-exec env var. + +**Architecture:** Extends the shipped cloud-context MCP (`pkg/mcp/cloud/`) — see `docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md`. The `cloud.Server` holds the active target as in-memory session state and merges a provider-supplied env var into each `run_cli` child (`CLOUDSDK_CORE_PROJECT` for GCP, `AWS_PROFILE` for AWS); the agent never supplies the target flags (they stay deny-floored). AWS multi-account uses a per-source `accounts` list; triagent generates the `~/.aws/config` profiles at startup, so it holds no credentials. + +**Tech Stack:** Go (`os/exec`, `os`, `text/template`/string building, `gopkg.in/ini.v1` or hand-written ini), the existing `pkg/mcp/cloud` package, `testify`. + +**Folds into:** the `feature/cloud-context-mcp` branch (PR #53). Two sub-PRs (A: provider-agnostic core; B: provider impls + AWS accounts config + launcher wiring), each self-merged into the feature branch. + +--- + +## Contracts + +The provider-agnostic core (PR A) defines two new `Provider` interface methods; the gcp/aws impls (PR B) realize them. + +| Name | Producer | Consumer | Shape | +| ---- | -------- | -------- | ----- | +| `provider-active-target` | PR A (interface + fake) | PR B (gcp, aws) | `ActiveTargetEnv(targetID string) []string` — the env var(s) to set for a target. GCP: `["CLOUDSDK_CORE_PROJECT="]`. AWS: `["AWS_PROFILE="]`. | +| `provider-configured-targets` | PR A | PR B | `ConfiguredTargets() []Target` — targets the provider itself knows from config. AWS returns its `accounts` list; GCP returns `nil` (its set comes from `scope.projects`/inventory). | + +`Target` is `struct { ID, Name string }` (same shape as `Scope`; reused conceptually). + +## Conventions + +Inherit the cloud package's conventions (provider impls live in `pkg/mcp/cloud/providers//`; CLI-only; testify table tests over fixtures; the deny floor and `Allows` are unchanged). New rules: + +- **Active target is per-exec env, never `os.Setenv`.** The `Server` stores the active target ID in memory and builds the target env into each `execCLI` call (extending `subprocessEnv`). No process-global mutation, matching the probe fix. +- **The agent never names a profile or arbitrary target.** `set_active_target` validates the ID against the selectable set and rejects anything else; `--project`/`--account`/`--profile` stay deny-floored. +- **triagent generates AWS profiles, holds no credential.** Generated profiles are written to a managed, clearly-delimited block in `$HOME/.aws/config`; each is `role_arn` + the source's `source_profile` (the operator's SSO base). Generation is idempotent. + +--- + +## PR A — provider-agnostic active-target core (against the fake) + +### Task A1: `Target` type and the two `Provider` methods + +**Files:** +- Modify: `pkg/mcp/cloud/provider.go` +- Modify: `pkg/mcp/cloud/fake_test.go` +- Test: `pkg/mcp/cloud/server_test.go` + +- [ ] **Step 1: Write the failing test** — `fakeProvider` satisfies the extended interface. + +```go +// server_test.go (add) +func TestFakeProviderSatisfiesActiveTargetContract(t *testing.T) { + var p Provider = &fakeProvider{} + require.NotNil(t, p) + // compile-time: the interface now includes ActiveTargetEnv + ConfiguredTargets +} +``` + +- [ ] **Step 2: Run to verify it fails** — `go test ./pkg/mcp/cloud/ -run TestFakeProviderSatisfiesActiveTargetContract` → FAIL (fakeProvider missing methods). + +- [ ] **Step 3: Add `Target` + the interface methods** in `provider.go`: + +```go +// Target is one selectable project (gcp) or account (aws) the agent may make active. +type Target struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Provider (add to the interface): + // 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 +``` + +- [ ] **Step 4: Implement on `fakeProvider`** in `fake_test.go`: + +```go +func (f *fakeProvider) ConfiguredTargets() []Target { return f.targets } +func (f *fakeProvider) ActiveTargetEnv(id string) []string { return []string{"FAKE_TARGET=" + id} } +``` +Add `targets []Target` to the `fakeProvider` struct. + +- [ ] **Step 5: Run** `go test ./pkg/mcp/cloud/ -run TestFakeProviderSatisfiesActiveTargetContract -v` → PASS. + +- [ ] **Step 6: Commit** `feat(cloud): Target type and active-target provider methods (#47-followup)`. + +### Task A2: Server active-target state + selectable set + apply + +**Files:** +- Modify: `pkg/mcp/cloud/server.go` +- Test: `pkg/mcp/cloud/server_test.go` + +- [ ] **Step 1: Write failing tests** for the selectable set and env application: + +```go +func TestSelectableTargetsPrefersConfigured(t *testing.T) { + p := &fakeProvider{targets: []Target{{ID: "acct-1", Name: "one"}}} + s := newTestServer(t, p) // helper already present + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "acct-1", Name: "one"}}, got) +} + +func TestSetActiveTargetRejectsOutOfSet(t *testing.T) { + 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) +} + +func TestSubprocessEnvIncludesActiveTarget(t *testing.T) { + 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") +} +``` + +- [ ] **Step 2: Run** → FAIL (`selectableTargets`/`setActive`/`activeTarget` undefined). + +- [ ] **Step 3: Implement** on `Server` in `server.go`: + +```go +// add fields: activeTarget string + +// selectableTargets returns the set the agent may choose from: the provider's +// configured targets (aws accounts) when present, else the scope projects, else +// (unconstrained) the live inventory scopes. +func (s *Server) selectableTargets(ctx context.Context) []Target { + if t := s.provider.ConfiguredTargets(); len(t) > 0 { + return t + } + if len(s.scope.Projects) > 0 { + out := make([]Target, 0, len(s.scope.Projects)) + for _, p := range s.scope.Projects { + out = append(out, Target{ID: p, Name: p}) + } + return out + } + inv, err := s.provider.Inventory(ctx, s.run) + if err != nil { + return nil + } + out := make([]Target, 0, len(inv.Scopes)) + for _, sc := range inv.Scopes { + out = append(out, Target{ID: sc.ID, Name: sc.Name}) + } + return out +} + +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) +} +``` +Extend `subprocessEnv()` to append `s.provider.ActiveTargetEnv(s.activeTarget)` when `s.activeTarget != ""`: + +```go +func (s *Server) subprocessEnv() []string { + env := minimalEnv(s.provider.EnvPassthrough()) + if s.activeTarget != "" { + env = append(env, s.provider.ActiveTargetEnv(s.activeTarget)...) + } + return env +} +``` + +- [ ] **Step 4: Run** `go test ./pkg/mcp/cloud/ -run 'TestSelectable|TestSetActive|TestSubprocessEnvIncludes' -race -v` → PASS. + +- [ ] **Step 5: Default-active + require-selection.** Add to `New`: if exactly one configured target, set `activeTarget` to it. Add a `requireActiveTarget()` guard `run` uses (Task A4). Test: + +```go +func TestSingleTargetIsDefaultActive(t *testing.T) { + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "only"}}}) + assert.Equal(t, "only", s.activeTarget) +} +func TestMultiTargetHasNoDefault(t *testing.T) { + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "a"}, {ID: "b"}}}) + assert.Equal(t, "", s.activeTarget) +} +``` +Implement the single-target default in `New` (after constructing `s`, call `if t := s.provider.ConfiguredTargets(); len(t)==1 { s.activeTarget = t[0].ID }`; for gcp single scope project, mirror via `selectableTargets` length check). Run → PASS. + +- [ ] **Step 6: Commit** `feat(cloud): server active-target state, selectable set, and env application`. + +### Task A3: `set_active_target` tool + +**Files:** +- Create: `pkg/mcp/cloud/tools_target.go` +- Modify: `pkg/mcp/cloud/specs.go`, `pkg/mcp/cloud/server.go` (registerOn) +- Test: `pkg/mcp/cloud/tools_test.go`, `pkg/mcp/cloud/tools_wire_test.go` + +- [ ] **Step 1: Write failing tests** (driven by `fakeProvider`): `set_active_target` with a valid ID sets the active target and returns the new `session_status`; an invalid ID returns a tool error; the wire test includes `set_active_target`. + +```go +func TestSetActiveTargetTool(t *testing.T) { + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", Valid: true}}) + _, out, err := s.setActiveTarget(context.Background(), nil, SetActiveTargetInput{Target: "acct-1"}) + require.NoError(t, err) + assert.True(t, out.Valid) + assert.Equal(t, "acct-1", s.activeTarget) + + res, _, _ := s.setActiveTarget(context.Background(), nil, SetActiveTargetInput{Target: "nope"}) + assert.True(t, res.IsError) +} +``` + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** `tools_target.go`: + +```go +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." + +type SetActiveTargetInput struct { + Target string `json:"target" jsonschema:"The project id (GCP) or account id (AWS) to activate, from list_inventory."` +} + +type SetActiveTargetOutput = IdentityStatus + +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.expectedIdentity, s.subprocessEnv()) + return nil, st, nil +} +``` +Register it in `registerOn` and add it to `ToolSpecs()` (between `session_status` and `run_cli`). + +- [ ] **Step 4: Run** `go test ./pkg/mcp/cloud/ -race -v` → PASS (incl. the wire test). + +- [ ] **Step 5: Commit** `feat(cloud): set_active_target tool and spec`. + +### Task A4: `run_cli` requires an active target when several exist + +**Files:** +- Modify: `pkg/mcp/cloud/server.go` (`run`), `pkg/mcp/cloud/tools_cli.go` +- Test: `pkg/mcp/cloud/tools_test.go` + +- [ ] **Step 1: Write failing test** — with multiple targets and none active, `run_cli` returns an actionable error; after `set_active_target`, it runs. + +```go +func TestRunCLIRequiresActiveTargetWhenMultiple(t *testing.T) { + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "a"}, {ID: "b"}}, binary: "/bin/echo", + allow: &CommandAllowlist{Commands: []Command{{Path: "echo"}}}}) + res, _, _ := s.runCLI(context.Background(), nil, RunCLIInput{Argv: []string{"echo", "x"}}) + assert.True(t, res.IsError) + assert.Contains(t, errText(res), "set_active_target") +} +``` +(`errText` reads the error content; add if absent.) + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** — in `Server.run`, before `validateArgv`: `if s.activeTarget == "" && len(s.selectableTargets(ctx)) > 1 { return CLIResult{}, errNoActiveTarget }` where `var errNoActiveTarget = errors.New("no active target; call set_active_target to choose one")`. `runCLI` surfaces it as a tool error. + +- [ ] **Step 4: Run** `go test ./pkg/mcp/cloud/ -race -v` → PASS. + +- [ ] **Step 5: Commit** `feat(cloud): run_cli requires an active target when several are configured`. + +### Task A5: `session_status` reports the active target + +**Files:** +- Modify: `pkg/mcp/cloud/provider.go` (`IdentityStatus` add `ActiveTarget string json:"active_target,omitempty"`), `pkg/mcp/cloud/tools_status.go` +- Test: `pkg/mcp/cloud/tools_test.go` + +- [ ] **Step 1: Write failing test** — `session_status` includes the active target. + +```go +func TestSessionStatusReportsActiveTarget(t *testing.T) { + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", Valid: true}}) + require.NoError(t, s.setActive("acct-1")) + _, out, _ := s.sessionStatus(context.Background(), nil, SessionStatusInput{}) + assert.Equal(t, "acct-1", out.ActiveTarget) +} +``` + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** — add `ActiveTarget` to `IdentityStatus`; in `sessionStatus`, set `st.ActiveTarget = s.activeTarget` on the returned status. (Probe leaves it empty; the server fills it.) + +- [ ] **Step 4: Run** → PASS. + +- [ ] **Step 5: Commit** `feat(cloud): session_status reports the active target`. + +--- + +## PR B — provider impls, AWS accounts config, launcher wiring + +### Task B1: GCP provider implements the active-target methods + +**Files:** +- Modify: `pkg/mcp/cloud/providers/gcp/provider.go` +- Test: `pkg/mcp/cloud/providers/gcp/provider_test.go` + +- [ ] **Step 1: Failing test:** + +```go +func TestGCPActiveTargetEnv(t *testing.T) { + p, _ := newWithBinary("/usr/bin/gcloud") + assert.Equal(t, []string{"CLOUDSDK_CORE_PROJECT=proj-1"}, p.ActiveTargetEnv("proj-1")) + assert.Nil(t, p.ConfiguredTargets()) +} +``` + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** on the gcp `Provider`: + +```go +func (p *Provider) ConfiguredTargets() []cloud.Target { return nil } +func (p *Provider) ActiveTargetEnv(id string) []string { return []string{"CLOUDSDK_CORE_PROJECT=" + id} } +``` + +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(cloud/gcp): active-target via CLOUDSDK_CORE_PROJECT (#43-followup)`. + +### Task B2: Profile config + AWS `accounts` on the profile model + +**Files:** +- Modify: `internal/profile/profile.go` (`CloudSource`), `internal/profile/validate.go` +- Test: `internal/profile/profile_test.go` + +- [ ] **Step 1: Failing test** — a `cloud:` aws source parses an `accounts` list and a `source_profile`; validation requires `source_profile` and at least one account when `accounts` is used, and unique account ids. + +```go +func TestCloudSourceAWSAccounts(t *testing.T) { + p, err := Parse([]byte(` +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"} +`)) + require.NoError(t, err) + require.NoError(t, p.Validate()) + assert.Len(t, p.Cloud[0].Accounts, 2) +} +``` +Add a negative case: duplicate account_id → `Validate` error; aws `accounts` without `source_profile` → error. + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** — extend `CloudSource`: + +```go +type CloudAccount struct { + AccountID string `yaml:"account_id"` + RoleARN string `yaml:"role_arn"` +} +// in CloudSource: + SourceProfile string `yaml:"source_profile,omitempty"` // aws SSO base profile for generated assume-role profiles + Accounts []CloudAccount `yaml:"accounts,omitempty"` +``` +Extend `Validate`: for an aws source with `Accounts`, require `SourceProfile`, each `account_id`/`role_arn` non-empty, and account ids unique. The single-`assumed_identity` form stays valid (one-account case). + +- [ ] **Step 4: Run** `go test ./internal/profile/ -race -v` → PASS. **Step 5: Commit** `feat(profile): aws cloud accounts list + source_profile (#47-followup)`. + +### Task B3: AWS provider — configured targets, generated profiles, active-target env + +**Files:** +- Modify: `pkg/mcp/cloud/providers/aws/provider.go` +- Create: `pkg/mcp/cloud/providers/aws/profiles.go` (profile generation) +- Test: `pkg/mcp/cloud/providers/aws/provider_test.go`, `profiles_test.go` + +- [ ] **Step 1: Failing test for the profile generator** — given accounts + a source_profile + an alias, it produces a managed `~/.aws/config` block, idempotently. + +```go +func TestGenerateProfilesBlock(t *testing.T) { + dir := t.TempDir(); cfg := filepath.Join(dir, "config") + accs := []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/triage-readonly"}} + require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) + b, _ := os.ReadFile(cfg) + assert.Contains(t, string(b), "[profile triagent-cloud-prod-aws-111111111111]") + assert.Contains(t, string(b), "role_arn = arn:aws:iam::111111111111:role/triage-readonly") + assert.Contains(t, string(b), "source_profile = sso-admin") + // idempotent: second write does not duplicate + require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) + b2, _ := os.ReadFile(cfg) + assert.Equal(t, 1, strings.Count(string(b2), "[profile triagent-cloud-prod-aws-111111111111]")) +} +``` + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** `profiles.go`: `writeManagedProfiles(configPath, alias, sourceProfile string, accs []Account)` rewrites a delimited block (`# BEGIN triagent-cloud-` … `# END triagent-cloud-`) atomically (tmp-file-then-rename, the repo's `atomicWrite` idiom), replacing any prior block for that alias so it is idempotent. Profile name: `triagent-cloud--`. Define `profileName(alias, accountID)` for reuse. + +- [ ] **Step 4: Implement the provider methods.** `New` gains the accounts + alias + source_profile (passed from serve.go, Task B5); store `accounts []Account` and `alias`. On `New`, call `writeManagedProfiles($HOME/.aws/config or AWS_CONFIG_FILE, alias, sourceProfile, accounts)`. + +```go +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}) } + return out +} +func (p *Provider) ActiveTargetEnv(id string) []string { + return []string{"AWS_PROFILE=" + profileName(p.alias, id)} +} +``` +Test `ConfiguredTargets`/`ActiveTargetEnv` against a provider built with two accounts. + +- [ ] **Step 5: Run** `go test ./pkg/mcp/cloud/providers/aws/ -race -v` → PASS. **Step 6: Commit** `feat(cloud/aws): configured accounts, generated profiles, active-target env (#46-followup)`. + +### Task B4: AWS inventory reflects the configured accounts + +**Files:** +- Modify: `pkg/mcp/cloud/providers/aws/inventory.go` +- Test: `pkg/mcp/cloud/providers/aws/inventory_test.go` + +- [ ] **Step 1: Failing test** — when the provider has a configured `accounts` list, `Inventory` returns exactly those accounts (the reachable/selectable set), without calling `organizations list-accounts`. + +```go +func TestInventoryUsesConfiguredAccounts(t *testing.T) { + p := providerWithAccounts(t, []Account{{ID: "111111111111"}, {ID: "222222222222"}}) + inv, err := p.Inventory(context.Background(), failRun(t)) // run must NOT be called + require.NoError(t, err) + assert.Len(t, inv.Scopes, 2) +} +``` +(`failRun` fails the test if invoked.) + +- [ ] **Step 2: Run** → FAIL (current Inventory calls `organizations list-accounts`). + +- [ ] **Step 3: Implement** — when `len(p.accounts) > 0`, `Inventory` returns those as `Scopes` directly. When empty (single-account legacy), keep the existing `organizations list-accounts` + caller-account fallback. + +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `fix(cloud/aws): inventory reflects the configured accounts, not the whole org`. + +### Task B5: serve.go + mcpconfig wiring + +**Files:** +- Modify: `cmd/triagent-mcp/serve.go` (`runCloud`, env consts), `internal/preflight/mcpconfig.go`, `pkg/mcp/cloud/env.go` +- Test: `cmd/triagent-mcp/serve_cloud_test.go`, `internal/preflight/mcpconfig_test.go` + +- [ ] **Step 1: Failing test (mcpconfig)** — an aws `CloudSource` with `accounts` + `source_profile` emits the accounts + source_profile to the cloud subprocess (JSON env `TRIAGENT_CLOUD_AWS_ACCOUNTS`, `cloud.EnvAWSSourceProfile`), and a gcp source is unaffected. + +```go +func TestCloudSourceAWSAccountsEnv(t *testing.T) { + env, err := cloudSourceEnv(profile.CloudSource{Alias: "prod-aws", Provider: "aws", SourceProfile: "sso-admin", + Accounts: []profile.CloudAccount{{AccountID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}}}) + require.NoError(t, err) + assert.Contains(t, env[cloud.EnvAWSSourceProfile], "sso-admin") + assert.NotEmpty(t, env[cloud.EnvAWSAccounts]) // JSON array +} +``` + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** — add `cloud.EnvAWSAccounts = "TRIAGENT_CLOUD_AWS_ACCOUNTS"` (JSON) and `cloud.EnvAWSSourceProfile = "TRIAGENT_CLOUD_AWS_SOURCE_PROFILE"` to `env.go`. `cloudSourceEnv` JSON-encodes the accounts and sets the source_profile for aws sources. `runCloud` decodes them and passes them to `aws.New(...)`; for the single-`assumed_identity` legacy form, build a one-element accounts list. Pass the source alias through (already available as the MCP alias). + +- [ ] **Step 4: Run** `go test ./cmd/triagent-mcp/ ./internal/preflight/ -race -v` → PASS. + +- [ ] **Step 5: Full verification** — `make test-go`, `make lint`, and `make build` all green. Commit `feat(cloud): wire aws accounts + source_profile through serve and mcpconfig (#47-followup)`. + +### Task B6: docs + +**Files:** +- Modify: `docs/content/cloud-providers.md` +- Test: a fresh-reader pass per `feature-dev-workflow:writing-docs` (RED/GREEN), then `make docs`. + +- [ ] **Step 1:** Document `set_active_target`, the AWS `accounts` + `source_profile` config (with a multi-account example), the "one identity many projects (GCP) vs one role per account (AWS)" model, and that `run_cli` requires an active target when several are configured. Run the writing-docs fresh-reader loop; `make docs` builds clean. Commit `docs(cloud): document multi-account/project active-target selection`. + +--- + +## Self-review + +- **Spec coverage:** `set_active_target` (A3); selectable set incl. scope/inventory fallback (A2); per-exec env apply, never os.Setenv (A2 `subprocessEnv`); default-active + require-selection (A2/A4); GCP `CLOUDSDK_CORE_PROJECT` (B1); AWS accounts config (B2), generated profiles + `AWS_PROFILE` (B3); inventory honesty (B4); launcher wiring (B5); session_status active target (A5); docs (B6). The runtime-AssumeRole broker stays rejected (no task builds it). Region switching untouched (no task). +- **Placeholder scan:** the AWS profile-generation ini format, the env-var names, the tool schema, and all test bodies are spelled out; no TBD/TODO. +- **Type consistency:** `Target{ID,Name}`, `ActiveTargetEnv(string) []string`, `ConfiguredTargets() []Target`, `CloudAccount{AccountID,RoleARN}`, `SetActiveTargetInput{Target}`, `profileName(alias, accountID)`, `EnvAWSAccounts`/`EnvAWSSourceProfile` are used consistently across PR A and PR B. From ab11261cce124e7599e930a68c14060f400605e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 05:10:28 +0200 Subject: [PATCH 34/76] feat(cloud): active-target selection core (set_active_target) (#67) * feat(cloud): Target type and active-target provider methods (#47-followup) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): server active-target state, selectable set, and env application Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): set_active_target tool and spec Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): run_cli requires an active target when several are configured Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): session_status reports the active target Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): real providers satisfy the active-target interface The Provider interface gained ConfiguredTargets and ActiveTargetEnv, so the gcp and aws realizations and the providers-package probe double must implement them to keep the tree compiling. gcp pins CLOUDSDK_CORE_PROJECT and returns no configured set (its set is scope/inventory); aws pins AWS_PROFILE. The deployment-configured aws accounts list arrives with the AWS accounts config. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/fake_test.go | 5 ++ pkg/mcp/cloud/probe_test.go | 2 + pkg/mcp/cloud/provider.go | 20 ++++++++ pkg/mcp/cloud/providers/aws/provider.go | 13 +++++ pkg/mcp/cloud/providers/gcp/provider.go | 12 +++++ pkg/mcp/cloud/providers/probe_test.go | 12 +++-- pkg/mcp/cloud/server.go | 68 ++++++++++++++++++++++++- pkg/mcp/cloud/server_test.go | 60 ++++++++++++++++++++++ pkg/mcp/cloud/specs.go | 6 +++ pkg/mcp/cloud/tools_status.go | 1 + pkg/mcp/cloud/tools_target.go | 31 +++++++++++ pkg/mcp/cloud/tools_test.go | 48 +++++++++++++++++ pkg/mcp/cloud/tools_wire_test.go | 4 +- 13 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 pkg/mcp/cloud/tools_target.go diff --git a/pkg/mcp/cloud/fake_test.go b/pkg/mcp/cloud/fake_test.go index bdc72071..364b0919 100644 --- a/pkg/mcp/cloud/fake_test.go +++ b/pkg/mcp/cloud/fake_test.go @@ -15,6 +15,7 @@ type fakeProvider struct { identity IdentityStatus identityErr error envPassthrough []string + targets []Target } func (f *fakeProvider) Name() string { @@ -49,3 +50,7 @@ func (f *fakeProvider) Inventory(context.Context, RunFunc) (Inventory, error) { 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} } diff --git a/pkg/mcp/cloud/probe_test.go b/pkg/mcp/cloud/probe_test.go index 4925233b..8ecaf689 100644 --- a/pkg/mcp/cloud/probe_test.go +++ b/pkg/mcp/cloud/probe_test.go @@ -30,6 +30,8 @@ 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) Identity(ctx context.Context, run RunFunc, _ string) (IdentityStatus, error) { res, err := run(ctx, nil) if err != nil { diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go index 32d1bee1..c354af94 100644 --- a/pkg/mcp/cloud/provider.go +++ b/pkg/mcp/cloud/provider.go @@ -41,6 +41,21 @@ type Provider interface { // 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 +} + +// Target is one selectable project (gcp) or account (aws) the agent may make +// active via set_active_target. +type Target struct { + ID string `json:"id"` + Name string `json:"name"` } // RunFunc is the harness exec core, injected into providers so they never exec @@ -67,6 +82,11 @@ type IdentityStatus struct { 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 diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go index 85da7afc..d4527b78 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -119,6 +119,19 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { } } +// ConfiguredTargets is the deployment-configured account set. The single-account +// deployment carries no accounts list, so the selectable set comes from the +// server's inventory; the multi-account accounts list arrives with the AWS +// accounts config. +func (p *Provider) ConfiguredTargets() []cloud.Target { return nil } + +// ActiveTargetEnv pins the aws CLI to the active account via AWS_PROFILE, the +// generated assume-role profile for that account. 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 + "=" + id} +} + // 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 diff --git a/pkg/mcp/cloud/providers/gcp/provider.go b/pkg/mcp/cloud/providers/gcp/provider.go index 3b819ead..23d78755 100644 --- a/pkg/mcp/cloud/providers/gcp/provider.go +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -97,6 +97,18 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { } } +// ConfiguredTargets is empty for gcp: the selectable set comes from the +// server's scope projects or live inventory, not the provider's own config. +func (p *Provider) ConfiguredTargets() []cloud.Target { return nil } + +// 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} +} + // 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. diff --git a/pkg/mcp/cloud/providers/probe_test.go b/pkg/mcp/cloud/providers/probe_test.go index 8d54a8b8..3b4a316c 100644 --- a/pkg/mcp/cloud/providers/probe_test.go +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -18,14 +18,16 @@ import ( // 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) 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) Identity(ctx context.Context, _ cloud.RunFunc, _ string) (cloud.IdentityStatus, error) { <-ctx.Done() diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index d03ca44e..6b6e4521 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -2,6 +2,7 @@ package cloud import ( "context" + "errors" "fmt" "os" "strings" @@ -42,6 +43,11 @@ type Server struct { 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 @@ -67,10 +73,54 @@ func New(opts Options) (*Server, error) { 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) when present, else the scope projects, else +// (unconstrained) the live inventory scopes. +func (s *Server) selectableTargets(ctx context.Context) []Target { + if t := s.provider.ConfiguredTargets(); len(t) > 0 { + return t + } + if len(s.scope.Projects) > 0 { + out := make([]Target, 0, len(s.scope.Projects)) + for _, p := range s.scope.Projects { + out = append(out, Target{ID: p, Name: p}) + } + return out + } + inv, err := s.provider.Inventory(ctx, s.run) + 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. @@ -93,18 +143,30 @@ func (s *Server) Run(ctx context.Context) error { // and allowlist. Providers and tools exec only through this RunFunc, never // directly: it 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 + } 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") + // 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. func (s *Server) subprocessEnv() []string { - return minimalEnv(s.provider.EnvPassthrough()) + env := minimalEnv(s.provider.EnvPassthrough()) + if s.activeTarget != "" { + env = append(env, s.provider.ActiveTargetEnv(s.activeTarget)...) + } + return env } // minimalEnv returns the subprocess environment built from os.Environ() filtered @@ -141,6 +203,10 @@ func (s *Server) registerOn(impl *mcp.Server) { 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, diff --git a/pkg/mcp/cloud/server_test.go b/pkg/mcp/cloud/server_test.go index 002cfb09..2311f9ff 100644 --- a/pkg/mcp/cloud/server_test.go +++ b/pkg/mcp/cloud/server_test.go @@ -1,12 +1,20 @@ package cloud import ( + "context" "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{}) @@ -15,6 +23,58 @@ func TestNewRequiresProvider(t *testing.T) { 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 TestSelectableTargetsFallsBackToScopeProjects(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{}, func(o *Options) { + o.Scope = ScopeAllowlist{Projects: []string{"prod", "staging"}} + }) + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "prod", Name: "prod"}, {ID: "staging", Name: "staging"}}, 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) +} + +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) +} + +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 diff --git a/pkg/mcp/cloud/specs.go b/pkg/mcp/cloud/specs.go index 75e699bf..c567ec3f 100644 --- a/pkg/mcp/cloud/specs.go +++ b/pkg/mcp/cloud/specs.go @@ -20,6 +20,12 @@ func ToolSpecs() []toolspec.ToolSpec { 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", diff --git a/pkg/mcp/cloud/tools_status.go b/pkg/mcp/cloud/tools_status.go index 02ecaca5..60d600c7 100644 --- a/pkg/mcp/cloud/tools_status.go +++ b/pkg/mcp/cloud/tools_status.go @@ -24,5 +24,6 @@ func (s *Server) sessionStatus(ctx context.Context, _ *mcp.CallToolRequest, _ Se 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..87d477b3 --- /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.expectedIdentity, 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 index 9a705711..3ca7030c 100644 --- a/pkg/mcp/cloud/tools_test.go +++ b/pkg/mcp/cloud/tools_test.go @@ -2,8 +2,10 @@ package cloud import ( "context" + "strings" "testing" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -18,6 +20,52 @@ func newTestServer(t *testing.T, p Provider, opts ...func(*Options)) *Server { 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"}}}} diff --git a/pkg/mcp/cloud/tools_wire_test.go b/pkg/mcp/cloud/tools_wire_test.go index 9cf246c5..4042c67a 100644 --- a/pkg/mcp/cloud/tools_wire_test.go +++ b/pkg/mcp/cloud/tools_wire_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestTools_Registered confirms the four cloud tools are exposed and that the +// 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) { @@ -45,7 +45,7 @@ func TestTools_Registered(t *testing.T) { assert.True(t, cataloged[name], "tool %q registered but absent from ToolSpecs()", name) } - for _, want := range []string{"list_inventory", "session_status", "run_cli", "list_allowed_commands"} { + 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) } } From 2c01ab1c3740fdf3f7f3222edf9c65552f4464fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 05:37:58 +0200 Subject: [PATCH 35/76] feat(cloud): AWS multi-account active-target wiring and docs (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(profile): aws cloud accounts list + source_profile A multi-account aws cloud source carries a source_profile (the operator's SSO base) and an accounts list (one read-only role per account). Validation requires source_profile and at least one account with non-empty, source-unique account ids and role_arns when accounts is set; the single-assumed_identity profile form stays valid and is mutually exclusive with accounts. Towards #44 Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud/aws): configured accounts, generated profiles, active-target env aws.New takes the source alias, source_profile, and account set. ConfiguredTargets surfaces the accounts as the agent's selectable targets; ActiveTargetEnv pins AWS_PROFILE to each account's generated profile name. profiles.go writes a delimited, idempotent managed block per alias into ~/.aws/config (or $AWS_CONFIG_FILE), one assume-role profile per account layering its role_arn over the operator's source_profile. The write is tmp-file-then-rename and replaces only the alias's own block, so operator-authored profiles and other aliases survive. New generates the block at construction, so the profiles exist before any probe or run_cli on both the serve subprocess and launcher-side paths. Towards #44 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cloud/aws): inventory reflects the configured accounts, not the whole org When the source carries a configured accounts list, Inventory returns exactly those accounts as the reachable set and shells nothing — each account is its own read-only role, so an org-wide list-accounts would advertise accounts run_cli cannot enter. The single-account form keeps the organizations list-accounts + caller-account fallback. Towards #44 Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cloud): wire aws accounts + source_profile through serve and mcpconfig Adds cloud.EnvAWSAccounts (JSON), cloud.EnvAWSSourceProfile, and cloud.EnvAWSAlias. cloudSourceEnv emits them for a multi-account aws source (and no static AWS_PROFILE, since the server pins it per-exec from the active target); the single-account form is unchanged. runCloud decodes them and builds the provider through the factory. The factory (providers.New) and ProbeSource gain an Options/Source path carrying the alias, source_profile, and accounts, so the launcher-side probe builds the aws provider with its profile map — generating the same ~/.aws/config block the serve subprocess does, before any whoami. The launcher probe targets the default (first) account's generated profile; per-account validity is out of scope for v1. Interface changes beyond the plan: providers.New gained a variadic Options arg and providers.Source gained Alias/SourceProfile/Accounts, both required so the launcher-side provider has the profile map the plan called out as under-specified; cloud.EnvAWSAlias was added so serve and the launcher namespace generated profiles identically; profile.CloudAccount gained snake_case json tags to fix the env wire shape. Towards #44 Co-Authored-By: Claude Opus 4.8 (1M context) * docs(cloud): document multi-account/project active-target selection Adds the set_active_target tool, the AWS accounts + source_profile multi-account config (with a generated-profiles explanation and example), the GCP-one-identity- many-projects vs AWS-one-account-per-role model, and the run_cli-requires-an-active- target rule. Reconciles the pinned-identity, scope-by-omission, and cloud-block sections with the new bounded-selection behavior. Towards #44 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 39 +++++- cmd/triagent-mcp/serve_cloud_test.go | 30 +++++ docs/content/cloud-providers.md | 54 ++++++-- internal/preflight/mcpconfig.go | 23 +++- internal/preflight/mcpconfig_test.go | 43 +++++++ internal/preflight/preflight.go | 8 ++ internal/profile/profile.go | 30 ++++- internal/profile/profile_test.go | 91 ++++++++++++++ internal/profile/validate.go | 48 ++++++- pkg/mcp/cloud/env.go | 13 ++ pkg/mcp/cloud/providers/aws/inventory.go | 18 ++- pkg/mcp/cloud/providers/aws/inventory_test.go | 19 +++ pkg/mcp/cloud/providers/aws/profiles.go | 117 ++++++++++++++++++ pkg/mcp/cloud/providers/aws/profiles_test.go | 92 ++++++++++++++ pkg/mcp/cloud/providers/aws/provider.go | 88 ++++++++++--- pkg/mcp/cloud/providers/aws/provider_test.go | 45 +++++++ pkg/mcp/cloud/providers/probe.go | 45 +++++-- pkg/mcp/cloud/providers/probe_test.go | 23 ++++ pkg/mcp/cloud/providers/registry.go | 31 ++++- pkg/mcp/cloud/providers/registry_test.go | 29 +++++ 20 files changed, 836 insertions(+), 50 deletions(-) create mode 100644 pkg/mcp/cloud/providers/aws/profiles.go create mode 100644 pkg/mcp/cloud/providers/aws/profiles_test.go diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index ced36e89..7f4c1c83 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -13,6 +13,7 @@ import ( "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/git" "github.com/sourcehawk/triagent/pkg/mcp/incidentio" "github.com/sourcehawk/triagent/pkg/mcp/k8s" @@ -441,7 +442,10 @@ func runProm(ctx context.Context, f serveFlags) error { // 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. +// cloud.EnvExpectedIdentity), never argv. A multi-account aws source additionally +// 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. 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) @@ -450,7 +454,15 @@ func runCloud(ctx context.Context, f serveFlags) error { if err != nil { return fmt.Errorf("build cloud mcp server: %w", err) } - provider, err := providers.New(f.cloudProvider) + accounts, err := parseAWSAccounts(os.Getenv(cloud.EnvAWSAccounts)) + 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, + }) if err != nil { return err } @@ -483,6 +495,29 @@ func parseCloudScope(raw string) (cloud.ScopeAllowlist, error) { 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"` + } + 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}) + } + return accounts, 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 index 6358782a..bccde5ef 100644 --- a/cmd/triagent-mcp/serve_cloud_test.go +++ b/cmd/triagent-mcp/serve_cloud_test.go @@ -65,3 +65,33 @@ func TestRunCloud_MalformedScopeAborts(t *testing.T) { 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/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 64f52ced..bd33d1c8 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -12,7 +12,7 @@ The MCP is read-only by construction, not by convention. The agent supplies argu ## 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) but has no tool to choose, change, or authenticate one. The deployment grants that 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 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. @@ -117,6 +117,39 @@ Scope the trust `Principal` to the specific operator users or SSO role rather th 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 pinned `assumed_identity`. 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 the single-profile setup above reaches exactly one account. When an investigation crosses accounts, configure the source's `accounts` list instead of `profile`: one entry per account, each a read-only `role_arn` plus the account id the agent selects by. The source also names a `source_profile`, the operator's own SSO base the generated profiles assume from. + +```yaml +cloud: + - alias: prod-aws + provider: aws + assumed_identity: arn:aws:iam::111111111111:role/triage-readonly + 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"} +``` + +You do not pre-create an `~/.aws/config` profile per account. Triagent generates one read-only assume-role profile per `accounts` entry at session start, into a managed block in your `~/.aws/config` (or `$AWS_CONFIG_FILE`) delimited by `# BEGIN triagent-cloud-` / `# END triagent-cloud-` markers. The block is rewritten idempotently and never touches profiles you authored yourself or another alias's block. Each generated profile layers its account's `role_arn` over `source_profile`, exactly as the single-account profile does by hand — triagent still stores no credential; the AWS CLI performs the assume-role from your SSO base. + +Give each account's role the same read-only permission and trust policies as the single-account role above. `assumed_identity` is the role ARN the agent's default account validates against; the connections panel shows that default account's validity (per-account validity is not surfaced in the panel). + +`accounts` and `profile` are mutually exclusive: a single-account source sets `profile`, a multi-account source sets `accounts` + `source_profile`. + +## 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). 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, which is why AWS needs the `accounts` list and GCP does not: + +- **GCP — one identity, many projects.** A single impersonated read-only service account can be granted viewer on every in-scope 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 `scope.projects` (or, when that axis is empty, the projects `list_inventory` surfaces). +- **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. @@ -146,13 +179,18 @@ cloud: # For aws, the role ARN the assumed-role caller must resolve to. Validity # checks the resolved caller against this exact ARN. assumed_identity: arn:aws:iam::123456789012:role/triage-readonly - # aws-only: the AWS_PROFILE the harness selects for credentials. Its - # role_arn is the read-only role, with the operator's base as - # source_profile. gcp ignores this field. + # aws single-account: the AWS_PROFILE the harness selects for credentials. + # Its role_arn is the read-only role, with the operator's base as + # source_profile. Mutually exclusive with accounts; gcp ignores it. profile: triage-readonly + # aws multi-account: set source_profile + accounts instead of profile to span + # several accounts the agent selects among via set_active_target. + # source_profile: sso-admin + # accounts: + # - {account_id: "111111111111", role_arn: "arn:aws:iam::111111111111:role/triage-readonly"} scope: regions: [eu-west-1] # enforced on run_cli argv. - accounts: ["123456789012"] # informational; account reach is bounded by the pinned role. + accounts: ["123456789012"] # informational scope note; distinct from the source-level accounts list. ``` The fields: @@ -160,7 +198,9 @@ 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` — the canonical pinned identity shown in the connections panel: a service-account email for GCP, a role ARN for AWS. GCP impersonates it directly. AWS checks it as the expected role ARN for strict validity. -- `profile` — AWS only. The `AWS_PROFILE` selector for the assume-role profile that produces credentials. GCP ignores it. +- `profile` — AWS single-account only. The `AWS_PROFILE` selector for the assume-role profile that produces credentials. Mutually exclusive with `accounts`; GCP ignores it. +- `source_profile` — AWS multi-account only. The operator's SSO base profile the generated per-account assume-role profiles layer their role over. Required when `accounts` is set. +- `accounts` — AWS multi-account only. The deployment-pinned account set the agent selects among via `set_active_target`; each entry is `{account_id, role_arn}`. See [Spanning several AWS accounts](#spanning-several-aws-accounts). This is the source-level selectable set, distinct from the informational `scope.accounts` note. - `scope` — the target allowlist (see below). - `command_allowlist_path` — an optional `run_cli` allowlist override (see below). Empty uses the provider's embedded default. @@ -177,7 +217,7 @@ scope: 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. -Scope constrains the value of an explicit flag; it does not force one to be present. If the agent omits `--project`, the CLI falls back to its own default target (the impersonated identity's default project, `CLOUDSDK_CORE_PROJECT`, or for AWS the configured `AWS_REGION`), which scope does not police. Hard project confinement therefore comes from the pinned identity's IAM, not from scope: grant the read-only roles only on the in-scope projects, as the setup above does, so an out-of-scope project is unreachable whatever the argv. Region has no equivalent IAM boundary, so treat region scope as a guardrail against explicit pivots rather than a hard limit. +Scope constrains the value of an explicit flag; it does not force one to be present. When a target is active, an omitted `--project` runs against that active target — an in-scope pin (`CLOUDSDK_CORE_PROJECT` for GCP, the active account's profile for AWS), not the CLI's ambient default — so a target-omitting command stays in-scope. Region still 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 therefore comes from the pinned identity's IAM, not from scope: grant the read-only roles only on the in-scope projects, as the setup above does, 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. diff --git a/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index d2686b26..daeeb678 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -188,10 +188,13 @@ func kubeEnv(in mcpConfigInputs) map[string]string { // 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 selects an -// assume-role profile (AWS_PROFILE) whose role_arn is the deployment's read-only -// role. The env-name constants come from the provider packages, never raw -// literals. +// directly (CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT); single-account AWS selects +// the operator's assume-role profile (AWS_PROFILE) whose role_arn is the +// deployment's read-only role. A multi-account AWS source instead carries its +// accounts and source_profile (TRIAGENT_CLOUD_AWS_ACCOUNTS, _SOURCE_PROFILE): the +// subprocess generates a 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) (map[string]string, error) { env := map[string]string{ cloud.EnvProvider: src.Provider, @@ -210,7 +213,17 @@ func cloudSourceEnv(src profile.CloudSource) (map[string]string, error) { case "gcp": env[gcp.EnvImpersonate] = src.AssumedIdentity case "aws": - env[aws.EnvProfile] = src.Profile + if len(src.Accounts) > 0 { + 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 + } else { + env[aws.EnvProfile] = src.Profile + } } return env, nil } diff --git a/internal/preflight/mcpconfig_test.go b/internal/preflight/mcpconfig_test.go index b7345512..ea89ee9a 100644 --- a/internal/preflight/mcpconfig_test.go +++ b/internal/preflight/mcpconfig_test.go @@ -466,4 +466,47 @@ func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithProfileAndExpectedRole assert.Equal(t, "triage-ro", env[aws.EnvProfile]) // gcp impersonation env must not leak onto an aws source. assert.NotContains(t, env, gcp.EnvImpersonate) + // The single-account form carries no accounts/source_profile env. + assert.NotContains(t, env, cloud.EnvAWSAccounts) + assert.NotContains(t, env, cloud.EnvAWSSourceProfile) +} + +func TestCloudSourceEnv_AWSAccounts_EmitsAccountsAndSourceProfile(t *testing.T) { + t.Parallel() + env, err := cloudSourceEnv(profile.CloudSource{ + Alias: "prod-aws", + Provider: "aws", + AssumedIdentity: "arn:aws:iam::111111111111:role/triage-ro", + 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"}, + }, + }) + require.NoError(t, err) + + assert.Equal(t, "sso-admin", env[cloud.EnvAWSSourceProfile]) + 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", + }) + require.NoError(t, err) + assert.NotContains(t, env, cloud.EnvAWSAccounts) + assert.NotContains(t, env, cloud.EnvAWSSourceProfile) } diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index b9847834..40f98ea7 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -20,6 +20,7 @@ import ( "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. @@ -260,10 +261,17 @@ func probeCloudSources(ctx context.Context, sources []profile.CloudSource, probe // provider and shells its whoami CLI. A construction error degrades to an // invalid status, never a session-fatal error. func defaultCloudProbe(ctx context.Context, src profile.CloudSource) cloud.IdentityStatus { + 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.ProbeSource(ctx, providers.Source{ Provider: src.Provider, AssumedIdentity: src.AssumedIdentity, Profile: src.Profile, + Alias: src.Alias, + SourceProfile: src.SourceProfile, + Accounts: accounts, }) } diff --git a/internal/profile/profile.go b/internal/profile/profile.go index e5ea9532..78c35534 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -158,15 +158,27 @@ type ExtraMCP struct { // AssumedIdentity is the canonical pinned identity shown in the connections // panel — a service-account email for gcp, a role ARN for aws. The two clouds // realize it through different env: gcp impersonates AssumedIdentity directly, -// while aws selects an assume-role profile (Profile) for credentials and checks -// AssumedIdentity (the role ARN) for strict validity. Profile is therefore -// aws-only; gcp ignores it. +// while aws selects an assume-role profile for credentials and checks +// AssumedIdentity (the role ARN) for strict validity. +// +// AWS has two shapes. The single-account form sets Profile (the operator's +// pre-existing AWS_PROFILE selector). The multi-account form sets SourceProfile +// (the operator's SSO base) plus Accounts (one read-only role per account); +// triagent generates a per-account assume-role profile layering each role over +// SourceProfile, and the agent selects among them via set_active_target. GCP +// spans its projects with one impersonated identity, so it ignores all three. type CloudSource struct { Alias string `yaml:"alias"` Provider string `yaml:"provider"` // "gcp" | "aws" AssumedIdentity string `yaml:"assumed_identity"` - Profile string `yaml:"profile,omitempty"` // aws AWS_PROFILE selector; ignored by gcp - Scope cloud.ScopeAllowlist `yaml:"scope,omitempty"` + Profile string `yaml:"profile,omitempty"` // aws single-account AWS_PROFILE selector; ignored by gcp + // SourceProfile is the operator's SSO base profile the generated multi-account + // assume-role profiles layer their role_arn over. Required when Accounts is set. + SourceProfile string `yaml:"source_profile,omitempty"` + // Accounts is the deployment-pinned multi-account set; each entry becomes a + // generated assume-role profile the agent may make active. aws-only. + Accounts []CloudAccount `yaml:"accounts,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 @@ -174,6 +186,14 @@ type CloudSource struct { CommandAllowlistPath string `yaml:"command_allowlist_path,omitempty"` } +// CloudAccount is one aws account in a multi-account cloud source: the account +// id the agent selects by, and the read-only role_arn triagent assumes into it +// from the source's SourceProfile. +type CloudAccount struct { + AccountID string `yaml:"account_id" json:"account_id"` + RoleARN string `yaml:"role_arn" json:"role_arn"` +} + 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 c32d99e6..2ebef0eb 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -307,6 +307,97 @@ func TestValidateCloudAWSMissingProfile(t *testing.T) { assert.Contains(t, err.Error(), "profile") } +const awsAccountsYAML = ` +name: example +description: test profile +auth: + kind: kubeconfig +playbooks: + entrypoint: a + closing: b +cloud: + - alias: prod-aws + provider: aws + assumed_identity: arn:aws:iam::111111111111:role/triage-readonly + 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", + AssumedIdentity: "arn:aws:iam::111111111111:role/triage-readonly", + 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 { diff --git a/internal/profile/validate.go b/internal/profile/validate.go index d3103b90..1936e33d 100644 --- a/internal/profile/validate.go +++ b/internal/profile/validate.go @@ -84,8 +84,8 @@ func (p *Profile) Validate() error { if c.AssumedIdentity == "" { errs = append(errs, fmt.Sprintf("cloud[%d].assumed_identity: required", i)) } - if c.Provider == "aws" && c.Profile == "" { - errs = append(errs, fmt.Sprintf("cloud[%d].profile: required when provider=aws", i)) + if c.Provider == "aws" { + errs = append(errs, validateAWSCredentials(i, c)...) } } @@ -94,3 +94,47 @@ func (p *Profile) Validate() error { } return errors.New("profile " + p.Name + " invalid:\n - " + strings.Join(errs, "\n - ")) } + +// validateAWSCredentials checks the two valid aws credential shapes. The +// multi-account form (accounts set) requires source_profile, at least one +// account, and non-empty account ids and role_arns that are each unique across +// the source. The single-account form (no accounts) requires the operator's +// pre-existing profile selector. The two are mutually exclusive: an accounts +// list pins its own profile per account, so a top-level profile alongside it is +// a misconfiguration. +func validateAWSCredentials(i int, c CloudSource) []string { + if len(c.Accounts) == 0 { + if c.Profile == "" { + return []string{fmt.Sprintf("cloud[%d].profile: required when provider=aws (or set accounts + source_profile)", i)} + } + return nil + } + + var errs []string + if c.Profile != "" { + errs = append(errs, fmt.Sprintf("cloud[%d].profile: must be empty when accounts is set (each account pins its own generated profile)", i)) + } + if c.SourceProfile == "" { + errs = append(errs, fmt.Sprintf("cloud[%d].source_profile: required when accounts is set", 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/pkg/mcp/cloud/env.go b/pkg/mcp/cloud/env.go index d74b7154..505716ff 100644 --- a/pkg/mcp/cloud/env.go +++ b/pkg/mcp/cloud/env.go @@ -20,4 +20,17 @@ const ( // threads it into the identity probe; the provider validates the resolved // identity against it. EnvExpectedIdentity = "TRIAGENT_CLOUD_EXPECTED_IDENTITY" + // EnvAWSAccounts carries the aws multi-account set as a JSON array of + // {account_id, role_arn} objects. The serve subprocess decodes it and builds + // the aws provider's configured targets and generated assume-role profiles. + // Empty for gcp and for the single-account aws form. + 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" ) diff --git a/pkg/mcp/cloud/providers/aws/inventory.go b/pkg/mcp/cloud/providers/aws/inventory.go index 2b59d6f3..f5d5a9c6 100644 --- a/pkg/mcp/cloud/providers/aws/inventory.go +++ b/pkg/mcp/cloud/providers/aws/inventory.go @@ -21,7 +21,15 @@ type organizationsAccount struct { Status string `json:"Status"` } -// Inventory projects the AWS accounts the pinned identity can read. The primary +// Inventory projects the AWS accounts the pinned identity can read. +// +// When the source is configured with an accounts list, the reachable set is +// exactly those accounts: each is its own read-only role, so the configured set +// already describes what run_cli can reach. Inventory returns them directly and +// shells nothing — an org-wide list-accounts would over-advertise accounts the +// role cannot enter. +// +// Without a configured accounts list (the single-account form), the primary // source is `aws organizations list-accounts`; only when that fails with an // Organizations-unavailable condition (AccessDenied or the account not being a // member of an organization) does it fall back to the single account the caller @@ -30,6 +38,14 @@ type organizationsAccount struct { // behind the single-account fallback. Both commands are allowlisted so the // projection works under the validated run core. func (p *Provider) Inventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { + if len(p.accounts) > 0 { + scopes := make([]cloud.Scope, 0, len(p.accounts)) + for _, a := range p.accounts { + scopes = append(scopes, cloud.Scope{ID: a.ID, Name: a.ID}) + } + return cloud.Inventory{Scopes: scopes}, nil + } + res, err := run(ctx, []string{"organizations", "list-accounts", "--output", "json"}) if err != nil { return cloud.Inventory{}, fmt.Errorf("aws organizations list-accounts: %w", err) diff --git a/pkg/mcp/cloud/providers/aws/inventory_test.go b/pkg/mcp/cloud/providers/aws/inventory_test.go index 63053b0b..1225c910 100644 --- a/pkg/mcp/cloud/providers/aws/inventory_test.go +++ b/pkg/mcp/cloud/providers/aws/inventory_test.go @@ -17,6 +17,25 @@ const listAccountsOutput = `{ ] }` +// TestInventoryUsesConfiguredAccounts proves a provider built with a configured +// accounts list reports exactly those accounts as the reachable set, without +// calling organizations list-accounts — the run func must never be invoked. +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"}, + }) + failRun := func(_ context.Context, argv []string) (cloud.CLIResult, error) { + t.Fatalf("Inventory must not shell the CLI when accounts 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: "111111111111", Name: "111111111111"}, inv.Scopes[0]) + assert.Equal(t, cloud.Scope{ID: "222222222222", Name: "222222222222"}, inv.Scopes[1]) +} + func TestInventoryProjectsActiveAccounts(t *testing.T) { f := &fakeRun{results: map[string]cloud.CLIResult{ "organizations list-accounts": {Stdout: listAccountsOutput}, diff --git a/pkg/mcp/cloud/providers/aws/profiles.go b/pkg/mcp/cloud/providers/aws/profiles.go new file mode 100644 index 00000000..5d0aca0e --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/profiles.go @@ -0,0 +1,117 @@ +package aws + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// 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 +} + +// writeManagedProfiles writes (or replaces) the managed assume-role profiles for +// one cloud source's accounts into configPath, atomically and idempotently. Each +// account gets a [profile triagent-cloud--] section layering +// its role_arn over sourceProfile (the operator's SSO base); triagent holds no +// credential — the aws CLI performs the assume-role from sourceProfile at run +// time. The section is bounded by # BEGIN/# END triagent-cloud- markers, +// so a rewrite replaces only that alias's block and leaves operator-authored +// profiles and other aliases' blocks untouched. Writing is tmp-file-then-rename +// so a crash never leaves a half-written config. +func writeManagedProfiles(configPath, 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") + + existing, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("aws: read config %s: %w", configPath, err) + } + merged := replaceBlock(string(existing), begin, end, block.String()) + return atomicWrite(configPath, []byte(merged)) +} + +// 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 +} + +// 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" + 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 +} + +// awsConfigPath resolves the file writeManagedProfiles writes to: AWS_CONFIG_FILE +// when set (so a deployment or test can redirect it), else $HOME/.aws/config — +// the location the aws CLI reads profiles from. The empty return (no HOME, no +// override) signals the caller to skip generation rather than write to a +// surprising path. +func awsConfigPath() string { + if override := os.Getenv("AWS_CONFIG_FILE"); override != "" { + return override + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".aws", "config") +} 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..e8c0b53b --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/profiles_test.go @@ -0,0 +1,92 @@ +package aws + +import ( + "os" + "path/filepath" + "strings" + "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 TestWriteManagedProfilesBlock(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "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(cfg, "prod-aws", "sso-admin", accs)) + + b, err := os.ReadFile(cfg) + require.NoError(t, err) + got := string(b) + assert.Contains(t, got, "# BEGIN triagent-cloud-prod-aws") + assert.Contains(t, got, "# END triagent-cloud-prod-aws") + assert.Contains(t, got, "[profile triagent-cloud-prod-aws-111111111111]") + assert.Contains(t, got, "[profile triagent-cloud-prod-aws-222222222222]") + assert.Contains(t, got, "role_arn = arn:aws:iam::111111111111:role/triage-readonly") + assert.Contains(t, got, "source_profile = sso-admin") +} + +// TestWriteManagedProfilesIdempotent proves a second write for the same alias +// replaces the prior block rather than appending a duplicate. +func TestWriteManagedProfilesIdempotent(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config") + accs := []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}} + require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) + require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) + + b, err := os.ReadFile(cfg) + require.NoError(t, err) + got := string(b) + 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")) +} + +// TestWriteManagedProfilesPreservesForeignContent proves the managed block is +// delimited: pre-existing operator profiles outside it survive a rewrite. +func TestWriteManagedProfilesPreservesForeignContent(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config") + foreign := "[profile sso-admin]\nsso_start_url = https://example.awsapps.com/start\n\n" + require.NoError(t, os.WriteFile(cfg, []byte(foreign), 0o600)) + + accs := []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}} + require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) + require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) + + b, err := os.ReadFile(cfg) + require.NoError(t, err) + got := string(b) + assert.Contains(t, got, "[profile sso-admin]") + assert.Contains(t, got, "sso_start_url = https://example.awsapps.com/start") + assert.Equal(t, 1, strings.Count(got, "[profile sso-admin]"), "foreign content must not be duplicated") +} + +// TestWriteManagedProfilesTwoAliases proves two managed blocks for different +// aliases coexist: rewriting one leaves the other intact. +func TestWriteManagedProfilesTwoAliases(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config") + require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-prod", + []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}})) + require.NoError(t, writeManagedProfiles(cfg, "staging-aws", "sso-staging", + []Account{{ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r"}})) + require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-prod", + []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}})) + + b, err := os.ReadFile(cfg) + require.NoError(t, err) + got := string(b) + assert.Contains(t, got, "# BEGIN triagent-cloud-prod-aws") + assert.Contains(t, got, "# BEGIN triagent-cloud-staging-aws") + assert.Equal(t, 1, strings.Count(got, "# BEGIN triagent-cloud-prod-aws")) + assert.Equal(t, 1, strings.Count(got, "# BEGIN triagent-cloud-staging-aws")) +} diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go index d4527b78..1d2ab91a 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -44,11 +44,35 @@ var _ cloud.Provider = (*Provider)(nil) // 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, and the read-only role_arn triagent generates an assume-role +// profile for, layered over the source's SSO base. +type Account struct { + ID string + RoleARN 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 +} + // 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 @@ -56,7 +80,13 @@ type Provider struct { // 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() (*Provider, error) { +// +// When opts carries accounts, New generates the per-account assume-role profiles +// into ~/.aws/config (or $AWS_CONFIG_FILE) before returning, so the profiles +// exist for both the serve subprocess and any launcher-side probe that runs the +// CLI under AWS_PROFILE. 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) @@ -65,17 +95,31 @@ func New() (*Provider, error) { if err != nil { return nil, fmt.Errorf("aws: resolve aws binary to absolute path: %w", err) } - return newWithBinary(abs) + 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. -func newWithBinary(binary string) (*Provider, error) { +// is the seam tests inject a fixed path through, bypassing exec.LookPath. At most +// one Options is honored; the zero value is the single-account legacy form. +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) } - return &Provider{binary: binary, allowlist: &list}, nil + var o Options + if len(opts) > 0 { + o = opts[0] + } + if len(o.Accounts) > 0 { + cfg := awsConfigPath() + if cfg == "" { + return nil, fmt.Errorf("aws: cannot resolve ~/.aws/config (no HOME and no AWS_CONFIG_FILE) to generate account profiles") + } + if err := writeManagedProfiles(cfg, 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 } // Name reports the provider identifier. @@ -119,17 +163,33 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { } } -// ConfiguredTargets is the deployment-configured account set. The single-account -// deployment carries no accounts list, so the selectable set comes from the -// server's inventory; the multi-account accounts list arrives with the AWS -// accounts config. -func (p *Provider) ConfiguredTargets() []cloud.Target { return nil } +// ConfiguredTargets is the deployment-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. The single-account deployment +// carries no accounts list and returns nil, so the selectable set comes from the +// server's inventory instead. +func (p *Provider) ConfiguredTargets() []cloud.Target { + if len(p.accounts) == 0 { + return nil + } + out := make([]cloud.Target, 0, len(p.accounts)) + for _, a := range p.accounts { + out = append(out, cloud.Target{ID: a.ID, Name: a.ID}) + } + return out +} -// ActiveTargetEnv pins the aws CLI to the active account via AWS_PROFILE, the -// generated assume-role profile for that account. The value is a profile name, -// not a credential: the CLI performs the assume-role from the operator's base. +// ActiveTargetEnv pins the aws CLI to the active account via AWS_PROFILE. For a +// configured account it names the generated assume-role profile +// (triagent-cloud--); the single-account legacy form passes +// the id through as the profile name directly. Either way 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 + "=" + id} + if len(p.accounts) == 0 { + return []string{EnvProfile + "=" + id} + } + return []string{EnvProfile + "=" + profileName(p.alias, id)} } // EnvPassthrough lists the env var NAMES the aws subprocess needs forwarded: diff --git a/pkg/mcp/cloud/providers/aws/provider_test.go b/pkg/mcp/cloud/providers/aws/provider_test.go index b3efd361..4ed23361 100644 --- a/pkg/mcp/cloud/providers/aws/provider_test.go +++ b/pkg/mcp/cloud/providers/aws/provider_test.go @@ -206,3 +206,48 @@ func keyOf(argv []string) string { } var errAccessDenied = errors.New("access denied (AccessDeniedException) when calling the ListAccounts operation") + +func TestConfiguredTargetsEmptyForSingleAccount(t *testing.T) { + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + assert.Nil(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")) +} + +// TestActiveTargetEnvSingleAccountPassthrough proves the legacy single-account +// provider (no alias, no accounts) treats the active id as the profile name +// directly, reproducing today's AWS_PROFILE= behavior. +func TestActiveTargetEnvSingleAccountPassthrough(t *testing.T) { + p, err := newWithBinary("/usr/bin/aws") + require.NoError(t, err) + assert.Equal(t, []string{"AWS_PROFILE=ro"}, p.ActiveTargetEnv("ro")) +} + +// providerWithAccounts builds an aws provider with a generated-profile config +// pointed at a temp AWS config file so construction's writeManagedProfiles call +// does not touch the developer's ~/.aws/config. +func providerWithAccounts(t *testing.T, alias string, accs []Account) *Provider { + t.Helper() + t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "config")) + p, err := newWithBinary("/usr/bin/aws", Options{Alias: alias, SourceProfile: "sso-admin", Accounts: accs}) + require.NoError(t, err) + return p +} diff --git a/pkg/mcp/cloud/providers/probe.go b/pkg/mcp/cloud/providers/probe.go index 2273a520..4e874cff 100644 --- a/pkg/mcp/cloud/providers/probe.go +++ b/pkg/mcp/cloud/providers/probe.go @@ -26,14 +26,24 @@ var probeTimeout = 15 * time.Second // 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 (aws only) the assume-role profile. -// It carries exactly what ProbeSource needs without coupling this package to -// the launcher's profile type. +// 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. +// +// AWS has two forms. The single-account form sets Profile (the operator's +// AWS_PROFILE selector). The multi-account form sets Alias, SourceProfile, and +// Accounts; ProbeSource generates the per-account profiles and probes the default +// (first) account's generated profile — per-account validity is out of scope for +// v1, so the panel reflects the source's default-target validity. gcp ignores +// all four. type Source struct { Provider string AssumedIdentity string - Profile string // aws AWS_PROFILE selector; ignored by gcp + Profile string // aws single-account AWS_PROFILE selector; ignored by gcp + Alias string // aws multi-account: the generated profiles' namespace + SourceProfile string // aws multi-account: the operator's SSO base profile + Accounts []aws.Account } // ProbeSource constructs the source's provider and runs the read-only identity @@ -43,7 +53,11 @@ type Source struct { // 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 { - p, err := New(src.Provider) + p, err := New(src.Provider, Options{ + AWSAlias: src.Alias, + AWSSourceProfile: src.SourceProfile, + AWSAccounts: src.Accounts, + }) if err != nil { return cloud.IdentityStatus{ Provider: src.Provider, @@ -108,15 +122,28 @@ func sourceEnvFor(p passthroughLister, src Source) []string { // 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. The env-name constants come from the provider packages, -// never raw literals. +// 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": - return map[string]string{aws.EnvProfile: src.Profile} + return map[string]string{aws.EnvProfile: awsProbeProfile(src)} default: return nil } } + +// awsProbeProfile is the AWS_PROFILE the launcher-side probe authenticates with: +// the default (first) account's generated profile for a multi-account source, +// else the operator's single-account profile selector. +func awsProbeProfile(src Source) string { + if len(src.Accounts) > 0 { + return aws.ProfileName(src.Alias, src.Accounts[0].ID) + } + return src.Profile +} diff --git a/pkg/mcp/cloud/providers/probe_test.go b/pkg/mcp/cloud/providers/probe_test.go index 3b4a316c..36ab890d 100644 --- a/pkg/mcp/cloud/providers/probe_test.go +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -101,6 +101,29 @@ func TestProbeSourceConstructionFailureKeepsPinnedIdentity(t *testing.T) { assert.NotEmpty(t, st.Hint) } +// TestCredentialEnvAWSMultiAccountTargetsDefaultProfile proves the launcher-side +// probe for a multi-account aws source pins AWS_PROFILE to the default (first) +// account's generated profile name, not the operator's raw profile. Per-account +// validity is out of scope for v1; the panel shows the default target's validity. +func TestCredentialEnvAWSMultiAccountTargetsDefaultProfile(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 multi-account probe must target the default account's generated profile") +} + +func TestCredentialEnvAWSSingleAccountUsesProfile(t *testing.T) { + env := credentialEnv(Source{Provider: "aws", Profile: "triage-ro"}) + assert.Equal(t, "triage-ro", env[aws.EnvProfile]) +} + // 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 } diff --git a/pkg/mcp/cloud/providers/registry.go b/pkg/mcp/cloud/providers/registry.go index 9c5feeec..027f0c06 100644 --- a/pkg/mcp/cloud/providers/registry.go +++ b/pkg/mcp/cloud/providers/registry.go @@ -15,17 +15,38 @@ import ( "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" ) -// New constructs the cloud.Provider for the named provider ("gcp" | "aws"). The -// concrete New() resolves the provider's CLI binary to an absolute path; a +// Options carries the multi-account config the aws provider needs from the +// profile's cloud source: the source alias (the generated profiles' namespace), +// the operator's SSO source_profile, and the account set. gcp ignores it. The +// zero value is the single-account / single-identity form, so callers that do +// not configure accounts call New(name) unchanged. +type Options struct { + AWSAlias string + AWSSourceProfile string + AWSAccounts []aws.Account +} + +// 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. -func New(name string) (cloud.Provider, error) { +// 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() case "aws": - return aws.New() + return aws.New(aws.Options{ + Alias: o.AWSAlias, + SourceProfile: o.AWSSourceProfile, + Accounts: o.AWSAccounts, + }) 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 index 36371caa..9b041a29 100644 --- a/pkg/mcp/cloud/providers/registry_test.go +++ b/pkg/mcp/cloud/providers/registry_test.go @@ -1,8 +1,10 @@ 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" ) @@ -43,3 +45,30 @@ func TestNew_UnknownProviderErrors(t *testing.T) { 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")) +} From a249699dae340363713fc4408e0f8ad3fa595f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 19:18:27 +0200 Subject: [PATCH 36/76] =?UTF-8?q?fix(mcp/cloud):=20break=20selectableTarge?= =?UTF-8?q?ts=E2=86=94run=20recursion=20via=20ungated=20exec=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving the selectable set from live inventory shelled through s.run, whose active-target gate consults selectableTargets again — unbounded recursion on New/run_cli for unconstrained GCP and single-account AWS sources (the in-package fake masked it by ignoring the injected RunFunc). Split out runValidated (no active-target gate) and derive inventory through it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/server.go | 16 +++++++++++++--- pkg/mcp/cloud/server_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index 6b6e4521..526f133d 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -97,7 +97,7 @@ func (s *Server) selectableTargets(ctx context.Context) []Target { } return out } - inv, err := s.provider.Inventory(ctx, s.run) + inv, err := s.provider.Inventory(ctx, s.runValidated) if err != nil { return nil } @@ -140,12 +140,22 @@ func (s *Server) Run(ctx context.Context) error { } // run is the harness exec core bound to this server's provider binary, scope, -// and allowlist. Providers and tools exec only through this RunFunc, never -// directly: it validates argv before handing it to the no-shell exec core. +// 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 } diff --git a/pkg/mcp/cloud/server_test.go b/pkg/mcp/cloud/server_test.go index 2311f9ff..7784b201 100644 --- a/pkg/mcp/cloud/server_test.go +++ b/pkg/mcp/cloud/server_test.go @@ -48,6 +48,42 @@ func TestSelectableTargetsFallsBackToInventory(t *testing.T) { 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) +} + func TestSetActiveTargetRejectsOutOfSet(t *testing.T) { t.Parallel() s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}}) From d2c0489826206018759aaf4bcbd4b94b0e9e6286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 19:18:27 +0200 Subject: [PATCH 37/76] fix(mcp/cloud): deny-floor the CLI wire-tracing flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gcloud --log-http and aws --debug echo signed request metadata and auth headers/session tokens to stderr, which run_cli returns to the agent — a leak past the minimal-env boundary. Add both to the hardcoded deny floor so they are rejected before exec. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/allowlist.go | 7 ++++--- pkg/mcp/cloud/validate_test.go | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/mcp/cloud/allowlist.go b/pkg/mcp/cloud/allowlist.go index ac5444c8..ca2f2439 100644 --- a/pkg/mcp/cloud/allowlist.go +++ b/pkg/mcp/cloud/allowlist.go @@ -44,14 +44,15 @@ type DenyFloor struct { // 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, plus argument prefixes that read local files or reach the network -// (local-file read and SSRF vectors). +// and flags, the wire-tracing flags that echo signed request metadata and auth +// headers to stderr (gcloud --log-http, aws --debug), 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", + "--flags-file", "--access-token-file", "--log-http", "--debug", }, ArgPrefixes: []string{"file://", "fileb://", "@", "http://", "https://"}, } diff --git a/pkg/mcp/cloud/validate_test.go b/pkg/mcp/cloud/validate_test.go index 199a40e5..411db54e 100644 --- a/pkg/mcp/cloud/validate_test.go +++ b/pkg/mcp/cloud/validate_test.go @@ -25,6 +25,8 @@ func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { {"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}, From be45f0ee400ec4fb5ff0ffa9978574a5a2d04589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 19:24:34 +0200 Subject: [PATCH 38/76] fix(server): probe cloud connections through the shared preflight prober MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connections panel had its own copy of the profile→providers.Source mapping that dropped the AWS multi-account fields (alias, source_profile, accounts), so a multi-account source probed with an empty AWS_PROFILE and showed as unavailable even when preflight (which threads them) succeeded. Route /api/connections through preflight.DefaultCloudProbe so both surfaces resolve the same identity, and extract the source mapping behind a guarded helper. Also serialize an empty cloud source set as [] rather than null so clients can always treat it as an array. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/preflight/preflight.go | 22 +++++++++---- internal/preflight/preflight_test.go | 33 ++++++++++++++++++++ internal/server/handlers_connections.go | 28 +++++------------ internal/server/handlers_connections_test.go | 6 +++- 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 40f98ea7..e526437e 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -244,7 +244,7 @@ func probeCloudSources(ctx context.Context, sources []profile.CloudSource, probe return nil } if probe == nil { - probe = defaultCloudProbe + probe = DefaultCloudProbe } out := make([]CloudSourceStatus, 0, len(sources)) for _, src := range sources { @@ -256,23 +256,33 @@ func probeCloudSources(ctx context.Context, sources []profile.CloudSource, probe return out } -// defaultCloudProbe is the real prober: it maps a profile cloud source to the +// DefaultCloudProbe is the real prober: it maps a profile cloud source to the // providers package's neutral Source 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. -func defaultCloudProbe(ctx context.Context, src profile.CloudSource) cloud.IdentityStatus { +// 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 DefaultCloudProbe(ctx context.Context, src profile.CloudSource) cloud.IdentityStatus { + return providers.ProbeSource(ctx, cloudProbeSource(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 rather than an empty AWS_PROFILE. +func cloudProbeSource(src profile.CloudSource) 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.ProbeSource(ctx, providers.Source{ + return providers.Source{ Provider: src.Provider, AssumedIdentity: src.AssumedIdentity, Profile: src.Profile, Alias: src.Alias, SourceProfile: src.SourceProfile, Accounts: accounts, - }) + } } // cloudSources returns the profile's read-only cloud connections, or nil when diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go index b088ba1b..316dee3e 100644 --- a/internal/preflight/preflight_test.go +++ b/internal/preflight/preflight_test.go @@ -16,6 +16,8 @@ import ( "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. @@ -306,3 +308,34 @@ func TestRun_CloudProviderConstructionErrorDegrades(t *testing.T) { 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) { + t.Parallel() + src := profile.CloudSource{ + Alias: "prod-aws", + Provider: "aws", + AssumedIdentity: "arn:aws:iam::111111111111:role/triage-ro", + 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) + + assert.Equal(t, providers.Source{ + Provider: "aws", + AssumedIdentity: "arn:aws:iam::111111111111:role/triage-ro", + 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"}, + }, + }, got) +} diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index c06d9ec5..c775ea88 100644 --- a/internal/server/handlers_connections.go +++ b/internal/server/handlers_connections.go @@ -12,9 +12,7 @@ import ( "time" "github.com/sourcehawk/triagent/internal/connections" - "github.com/sourcehawk/triagent/internal/profile" - "github.com/sourcehawk/triagent/pkg/mcp/cloud" - "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers" + "github.com/sourcehawk/triagent/internal/preflight" ) // Connection-management endpoints. The panel reads /api/connections to @@ -62,17 +60,17 @@ func (a *apiHandlers) connectionsResp(ctx context.Context) connectionsResponse { } // cloudConnections probes each profile cloud source at request time and projects -// the result into the read-only panel view. Returns nil when no profile or no -// cloud sources are configured. 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. +// 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 nil + return []cloudConnection{} } probe := a.cloudProbe if probe == nil { - probe = defaultCloudProbe + probe = preflight.DefaultCloudProbe } out := make([]cloudConnection, 0, len(a.prof.Cloud)) for _, src := range a.prof.Cloud { @@ -100,18 +98,6 @@ func (a *apiHandlers) cloudConnections(ctx context.Context) []cloudConnection { return out } -// defaultCloudProbe is the real request-time prober: it maps a profile cloud -// source to the providers package's neutral Source and runs ProbeSource, which -// constructs the provider and shells its whoami CLI, degrading a construction -// error to an invalid status. -func defaultCloudProbe(ctx context.Context, src profile.CloudSource) cloud.IdentityStatus { - return providers.ProbeSource(ctx, providers.Source{ - Provider: src.Provider, - AssumedIdentity: src.AssumedIdentity, - Profile: src.Profile, - }) -} - // handleGetConnections returns which integrations have a usable token, plus // profile boot config (slack_channel_prefix) and the request-time cloud // identity probe. diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go index 4047e719..db529d30 100644 --- a/internal/server/handlers_connections_test.go +++ b/internal/server/handlers_connections_test.go @@ -388,9 +388,13 @@ func TestGetConnections_NoCloudSources_OmitsOrEmptyCloud(t *testing.T) { 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.NewDecoder(rr.Body).Decode(&resp)) + require.NoError(t, json.Unmarshal([]byte(body), &resp)) assert.Empty(t, resp.Cloud, "no cloud sources means an empty cloud array") } From 9d0d354d71582ab947fa57c99b8d44d4bc8e52da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 19:25:44 +0200 Subject: [PATCH 39/76] docs(spec): document the set_active_target tool the cloud MCP ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implementation registers five tools (server.go, specs.go) but the design spec's tool surface listed four, omitting set_active_target — the target switch added with AWS multi-account support. Reconcile the spec to the shipped surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../superpowers/specs/2026-05-30-cloud-context-mcp-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md index 39aced7d..009c4581 100644 --- a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md +++ b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md @@ -28,10 +28,11 @@ This spec defines a single read-only cloud-context MCP that gives the agent that One package, `pkg/mcp/cloud/`, exposing `New(Options)` + `Run(ctx)` + a sibling `specs.go::ToolSpecs()`, registered with one `case "cloud"` in `cmd/triagent-mcp/serve.go` (ADR-0001) and selected at launch by `--provider=gcp|aws`. This mirrors the git MCP, which is one package bound per-repo via `--repo` and aliased `triagent-git-` at the `mcpconfig.go` wiring layer (`internal/preflight/mcpconfig.go`, ADR-0003); here the bound target is a cloud provider, aliased `triagent-cloud-`. Deployment config (provider, pinned identity, scope allowlist, command-allowlist override path) loads from the runtime profile (ADR-0008). -The tool surface is provider-agnostic and lives once in `specs.go`. It is deliberately thin: two typed tools where shaped output clearly pays its context cost, plus a gated CLI escape hatch for the long tail. +The tool surface is provider-agnostic and lives once in `specs.go`. It is deliberately thin: three typed tools where shaped output clearly pays its context cost, plus a gated CLI escape hatch for the long tail. - `list_inventory` — projects / accounts and the accessible resources within an allowlisted scope, so the agent can orient. - `session_status` — the read-only whoami: which pinned identity is active and whether it is valid. +- `set_active_target` — choose which project (GCP) or account (AWS) subsequent `run_cli` commands run against, from the configured set; an id outside that set is rejected, so the agent can never name a target the deployment did not configure. - `run_cli` — a gated, read-only `gcloud` / `aws` invocation for everything else, with argument tokens supplied as an array. - `list_allowed_commands` — the discovery tool that reads the same gating config `run_cli` enforces, so what is advertised is exactly what is permitted. @@ -39,7 +40,7 @@ Each typed tool calls through a `Provider` interface; selecting `--provider` cho ```mermaid flowchart TD - operator[operator agent] --> typed["typed tools
list_inventory · session_status"] + operator[operator agent] --> typed["typed tools
list_inventory · session_status · set_active_target"] operator --> disc["list_allowed_commands"] operator --> cli["run_cli
(argv tokens only)"] typed --> iface{{Provider interface}} From a6d19b2ff0d5479f590ef8975c2cec92e313e2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 20:26:30 +0200 Subject: [PATCH 40/76] fix(mcp/cloud): deny-floor --project so set_active_target is the only project switch The active-target design (specs/2026-05-31-cloud-active-target-selection-design.md, "--project ... stay on the deny floor; set_active_target is the only way to change the target") makes the active target the in-scope default applied via CLOUDSDK_CORE_PROJECT. An argv --project could still override it, steering a command away from session_status.active_target. Floor --project; project selection is now validated against scope.projects through set_active_target, and the argv scope axis is region/zone only. Public docs updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 10 +++++----- pkg/mcp/cloud/allowlist.go | 9 ++++++--- pkg/mcp/cloud/tools_test.go | 6 +++--- pkg/mcp/cloud/validate.go | 21 ++++++++++---------- pkg/mcp/cloud/validate_test.go | 34 +++++++++++++++++---------------- 5 files changed, 43 insertions(+), 37 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index bd33d1c8..7fd7e2eb 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -206,22 +206,22 @@ The fields: ## Scope allowlist -`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. +`scope` constrains which cloud targets the agent may reach, so it cannot pivot to an un-allowlisted project or region. Region/zone is enforced against `run_cli` argv. Project is not an argv axis: `--project` is deny-floored and the project is chosen with `set_active_target`, validated against `scope.projects`. 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 (argv-enforced) + projects: [prod-platform] # gcp projects the agent may set_active_target to 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) `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. +An empty (or omitted) `projects` or `regions` axis is unconstrained on that axis. A non-empty `regions` is a closed set: a `--region` or `--zone` value outside it fails validation before the command runs. A non-empty `projects` is the closed set `set_active_target` will accept. -Scope constrains the value of an explicit flag; it does not force one to be present. When a target is active, an omitted `--project` runs against that active target — an in-scope pin (`CLOUDSDK_CORE_PROJECT` for GCP, the active account's profile for AWS), not the CLI's ambient default — so a target-omitting command stays in-scope. Region still 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 therefore comes from the pinned identity's IAM, not from scope: grant the read-only roles only on the in-scope projects, as the setup above does, 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. +The active target is the effective default, so a command that omits the target flag runs against an in-scope 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 therefore comes from the pinned identity's IAM, not from scope: grant the read-only roles only on the in-scope projects, as the setup above does, 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-selecting flags (`--account`, `--profile`) never reach scope validation at all, because the deny floor rejects them first. +Identity- and target-selecting flags (`--account`, `--profile`, `--project`) never reach scope validation at all, because the deny floor rejects them first. ## Command allowlist diff --git a/pkg/mcp/cloud/allowlist.go b/pkg/mcp/cloud/allowlist.go index ca2f2439..c0543290 100644 --- a/pkg/mcp/cloud/allowlist.go +++ b/pkg/mcp/cloud/allowlist.go @@ -45,14 +45,17 @@ type DenyFloor struct { // 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), plus argument prefixes -// that read local files or reach the network (local-file read and SSRF vectors). +// 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", + "--flags-file", "--access-token-file", "--log-http", "--debug", "--project", }, ArgPrefixes: []string{"file://", "fileb://", "@", "http://", "https://"}, } diff --git a/pkg/mcp/cloud/tools_test.go b/pkg/mcp/cloud/tools_test.go index 3ca7030c..897ab8ec 100644 --- a/pkg/mcp/cloud/tools_test.go +++ b/pkg/mcp/cloud/tools_test.go @@ -148,13 +148,13 @@ func TestRunCLIRejectsOutOfScopeTarget(t *testing.T) { t.Parallel() p := &fakeProvider{ binary: "/bin/echo", - allowlist: &CommandAllowlist{Commands: []Command{{Path: "projects list"}}}, + allowlist: &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}}, } srv := newTestServer(t, p, func(o *Options) { - o.Scope = ScopeAllowlist{Projects: []string{"prod"}} + o.Scope = ScopeAllowlist{Regions: []string{"us-central1"}} }) res, _, err := srv.runCLI(context.Background(), nil, RunCLIInput{ - Argv: []string{"projects", "list", "--project", "other"}, + 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/validate.go b/pkg/mcp/cloud/validate.go index 9b8469b3..e052f094 100644 --- a/pkg/mcp/cloud/validate.go +++ b/pkg/mcp/cloud/validate.go @@ -6,11 +6,13 @@ import ( ) // ScopeAllowlist is the deployment's set of cloud targets any run_cli argv may -// reference. An empty field means that target axis is unconstrained. Project and -// region/zone are enforced here against argv (allowedFor maps --project and -// --region/--zone). Account reach is not validated at the argv layer: it is -// constrained by the pinned identity or role the session assumes, and the -// identity-selecting flags (--account, --profile) sit on the deny floor. +// reference. An empty field means that target axis is unconstrained. Region/zone +// is enforced here against argv (allowedFor maps --region/--zone). Project is not +// an argv axis: --project sits on the deny floor and the project is chosen only +// via set_active_target, validated against Projects there. Account reach is also +// not validated at the argv layer: it is constrained by the pinned identity or +// role the session assumes, and the identity-selecting flags (--account, +// --profile) sit on the deny floor. type ScopeAllowlist struct { Projects []string `json:"projects,omitempty"` Accounts []string `json:"accounts,omitempty"` @@ -18,13 +20,12 @@ type ScopeAllowlist struct { } // allowedFor maps a target-selecting flag to the ScopeAllowlist field whose -// membership a value of that flag must satisfy. The deny floor rejects identity -// flags (--account, --profile) before scope ever sees them, so scope governs -// only the location axes the agent is allowed to choose among. +// 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 "--project": - return s.Projects, true case "--region", "--zone": return s.Regions, true default: diff --git a/pkg/mcp/cloud/validate_test.go b/pkg/mcp/cloud/validate_test.go index 411db54e..6addd8ed 100644 --- a/pkg/mcp/cloud/validate_test.go +++ b/pkg/mcp/cloud/validate_test.go @@ -15,10 +15,10 @@ func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { argv []string ok bool }{ - {"allowed", []string{"compute", "instances", "list", "--project", "prod"}, true}, - {"allowed-region", []string{"compute", "instances", "list", "--project", "prod", "--region", "us-central1"}, true}, - {"bad-scope", []string{"compute", "instances", "list", "--project", "other"}, false}, - {"bad-region", []string{"compute", "instances", "list", "--project", "prod", "--region", "eu-west1"}, false}, + {"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}, @@ -55,15 +55,17 @@ func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { func TestValidateArgvEqualsFormFlag(t *testing.T) { t.Parallel() al := &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}} - scope := ScopeAllowlist{Projects: []string{"prod"}} - // --project=other 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", "--project=other"}, al, scope), - "expected --project=other (equals form) to fail the scope check") + 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.NoError(t, validateArgv([]string{"compute", "instances", "list", "--project=prod"}, al, scope), - "expected --project=prod (equals form, in scope) to validate") + 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) { @@ -73,7 +75,7 @@ func TestValidateArgvAllowsResourceOperand(t *testing.T) { // 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", "--project", "prod"}, al, scope), + []string{"compute", "instances", "describe", "my-vm"}, al, scope), "an allowlisted verb chain plus a resource operand must validate") } @@ -99,9 +101,9 @@ func TestValidateArgvRejectsMetacharInAnyPosition(t *testing.T) { func TestValidateArgvEmptyScopeAllowsAnyTarget(t *testing.T) { t.Parallel() - al := &CommandAllowlist{Commands: []Command{{Path: "projects list"}}} - // An empty scope means the deployment did not constrain targets; the scope - // check must not reject a --project then. - assert.NoError(t, validateArgv([]string{"projects", "list", "--project", "anything"}, al, ScopeAllowlist{}), + 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") } From 4896772e895c2adef9ec009428611041b0445616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 20:26:41 +0200 Subject: [PATCH 41/76] Revert "docs(spec): document the set_active_target tool the cloud MCP ships" This reverts commit 9d0d354d71582ab947fa57c99b8d44d4bc8e52da. --- .../superpowers/specs/2026-05-30-cloud-context-mcp-design.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md index 009c4581..39aced7d 100644 --- a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md +++ b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md @@ -28,11 +28,10 @@ This spec defines a single read-only cloud-context MCP that gives the agent that One package, `pkg/mcp/cloud/`, exposing `New(Options)` + `Run(ctx)` + a sibling `specs.go::ToolSpecs()`, registered with one `case "cloud"` in `cmd/triagent-mcp/serve.go` (ADR-0001) and selected at launch by `--provider=gcp|aws`. This mirrors the git MCP, which is one package bound per-repo via `--repo` and aliased `triagent-git-` at the `mcpconfig.go` wiring layer (`internal/preflight/mcpconfig.go`, ADR-0003); here the bound target is a cloud provider, aliased `triagent-cloud-`. Deployment config (provider, pinned identity, scope allowlist, command-allowlist override path) loads from the runtime profile (ADR-0008). -The tool surface is provider-agnostic and lives once in `specs.go`. It is deliberately thin: three typed tools where shaped output clearly pays its context cost, plus a gated CLI escape hatch for the long tail. +The tool surface is provider-agnostic and lives once in `specs.go`. It is deliberately thin: two typed tools where shaped output clearly pays its context cost, plus a gated CLI escape hatch for the long tail. - `list_inventory` — projects / accounts and the accessible resources within an allowlisted scope, so the agent can orient. - `session_status` — the read-only whoami: which pinned identity is active and whether it is valid. -- `set_active_target` — choose which project (GCP) or account (AWS) subsequent `run_cli` commands run against, from the configured set; an id outside that set is rejected, so the agent can never name a target the deployment did not configure. - `run_cli` — a gated, read-only `gcloud` / `aws` invocation for everything else, with argument tokens supplied as an array. - `list_allowed_commands` — the discovery tool that reads the same gating config `run_cli` enforces, so what is advertised is exactly what is permitted. @@ -40,7 +39,7 @@ Each typed tool calls through a `Provider` interface; selecting `--provider` cho ```mermaid flowchart TD - operator[operator agent] --> typed["typed tools
list_inventory · session_status · set_active_target"] + operator[operator agent] --> typed["typed tools
list_inventory · session_status"] operator --> disc["list_allowed_commands"] operator --> cli["run_cli
(argv tokens only)"] typed --> iface{{Provider interface}} From 1f11a9cd1908c3d5daf2cdcd7268b845913feae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 21:06:26 +0200 Subject: [PATCH 42/76] refactor(cloud): one AWS account model, validate the active account's own role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS had two shapes: a bare `profile:` single-account form (org-queried selectable set, AWS_PROFILE=) and an `accounts:` multi-account form. The single form was incoherent — selecting an org account set AWS_PROFILE to an account id that is not a profile name, and the org query over-advertised accounts the role cannot enter. Collapse to one model: an AWS source is always an `accounts:` list of {account_id, role_arn} (a single-account source is a one-entry list), and the selectable set is exactly that list, never a live `organizations list-accounts` query. Drop the `profile:` field, the org-query inventory, and the no-accounts provider branches. Validate per account: each account is its own role, so session_status/ set_active_target now re-probe the active account against its own role_arn (Provider.ExpectedIdentity) rather than one source-level identity — a non-default account no longer reports Valid:false in-session. Resolves the AWS multi-account identity and single-account-profile review findings. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 8 +- docs/content/cloud-providers.md | 56 +++++---- docs/content/profiles.md | 6 +- internal/preflight/mcpconfig.go | 30 ++--- internal/preflight/mcpconfig_test.go | 19 +-- internal/preflight/preflight.go | 1 - internal/preflight/preflight_test.go | 2 +- internal/profile/profile.go | 34 +++--- internal/profile/profile_test.go | 20 +-- internal/profile/validate.go | 24 ++-- internal/server/handlers_connections_test.go | 4 +- pkg/mcp/cloud/fake_test.go | 6 + pkg/mcp/cloud/probe_test.go | 1 + pkg/mcp/cloud/provider.go | 7 ++ pkg/mcp/cloud/providers/aws/inventory.go | 110 ++--------------- pkg/mcp/cloud/providers/aws/inventory_test.go | 114 +++--------------- pkg/mcp/cloud/providers/aws/provider.go | 40 +++--- pkg/mcp/cloud/providers/aws/provider_test.go | 29 ++--- pkg/mcp/cloud/providers/gcp/provider.go | 5 + pkg/mcp/cloud/providers/probe.go | 26 ++-- pkg/mcp/cloud/providers/probe_test.go | 26 ++-- pkg/mcp/cloud/server.go | 14 +++ pkg/mcp/cloud/server_test.go | 31 +++++ pkg/mcp/cloud/tools_status.go | 2 +- pkg/mcp/cloud/tools_target.go | 2 +- 25 files changed, 252 insertions(+), 365 deletions(-) diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index 7f4c1c83..a3930315 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -442,10 +442,10 @@ func runProm(ctx context.Context, f serveFlags) error { // 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. A multi-account aws source additionally -// 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. +// 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) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 7fd7e2eb..c28b22b8 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -56,18 +56,21 @@ The operator authenticates normally, for example: aws sso login ``` -Configure an `~/.aws/config` profile whose `role_arn` is the read-only role, with the operator's base profile as `source_profile`: +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 `~/.aws/config` 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`. -```ini -[profile triage-readonly] -role_arn = arn:aws:iam::123456789012:role/triage-readonly -source_profile = default -region = eu-west-1 +```yaml +cloud: + - alias: prod-aws + provider: aws + assumed_identity: arn:aws:iam::123456789012:role/triage-readonly # the default account's role + source_profile: default # the operator's SSO base + accounts: + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly"} ``` -The profile's `profile:` field selects that assume-role profile via `AWS_PROFILE`, and `assumed_identity` is the expected role ARN. The harness sets `AWS_PROFILE=` on the cloud MCP subprocess, so the AWS CLI assumes the read-only role from the operator's base credentials. As with GCP, the pin lives in environment, so `--profile` stays on the agent's deny floor. +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. `assumed_identity` is the default (first) account's role ARN, which the connections panel validates against. -The read-only role needs a permission policy and a trust policy. The minimal permission policy, scoped to exactly the default tool surface: +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 { @@ -115,11 +118,11 @@ The trust policy lets the operator's base principal assume the role: Scope the trust `Principal` to the specific operator users or SSO role rather than the whole account root where you can. 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. -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 pinned `assumed_identity`. A plain user or root ARN means the assume-role pin did not take effect and base credentials leaked through, so the source degrades. +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 the single-profile setup above reaches exactly one account. When an investigation crosses accounts, configure the source's `accounts` list instead of `profile`: one entry per account, each a read-only `role_arn` plus the account id the agent selects by. The source also names a `source_profile`, the operator's own SSO base the generated profiles assume from. +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: @@ -133,11 +136,9 @@ cloud: - {account_id: "333333333333", role_arn: "arn:aws:iam::333333333333:role/triage-readonly"} ``` -You do not pre-create an `~/.aws/config` profile per account. Triagent generates one read-only assume-role profile per `accounts` entry at session start, into a managed block in your `~/.aws/config` (or `$AWS_CONFIG_FILE`) delimited by `# BEGIN triagent-cloud-` / `# END triagent-cloud-` markers. The block is rewritten idempotently and never touches profiles you authored yourself or another alias's block. Each generated profile layers its account's `role_arn` over `source_profile`, exactly as the single-account profile does by hand — triagent still stores no credential; the AWS CLI performs the assume-role from your SSO base. - -Give each account's role the same read-only permission and trust policies as the single-account role above. `assumed_identity` is the role ARN the agent's default account validates against; the connections panel shows that default account's validity (per-account validity is not surfaced in the panel). +Triagent writes the generated profiles into a managed block in your `~/.aws/config` (or `$AWS_CONFIG_FILE`) delimited by `# BEGIN triagent-cloud-` / `# END triagent-cloud-` markers. The block is rewritten idempotently and never touches profiles you authored yourself or another alias's block; triagent stores no credential, the AWS CLI performs the assume-role from your SSO base. -`accounts` and `profile` are mutually exclusive: a single-account source sets `profile`, a multi-account source sets `accounts` + `source_profile`. +Give every account's role the same read-only permission and trust policies shown above. 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 @@ -176,18 +177,16 @@ cloud: - alias: prod-aws provider: aws - # For aws, the role ARN the assumed-role caller must resolve to. Validity - # checks the resolved caller against this exact ARN. + # For aws, the default account's role ARN, shown in the connections panel and + # validated by its probe. assumed_identity: arn:aws:iam::123456789012:role/triage-readonly - # aws single-account: the AWS_PROFILE the harness selects for credentials. - # Its role_arn is the read-only role, with the operator's base as - # source_profile. Mutually exclusive with accounts; gcp ignores it. - profile: triage-readonly - # aws multi-account: set source_profile + accounts instead of profile to span - # several accounts the agent selects among via set_active_target. - # source_profile: sso-admin - # accounts: - # - {account_id: "111111111111", role_arn: "arn:aws:iam::111111111111:role/triage-readonly"} + # 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}; a single-account source is a one-entry list. + accounts: + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly"} scope: regions: [eu-west-1] # enforced on run_cli argv. accounts: ["123456789012"] # informational scope note; distinct from the source-level accounts list. @@ -197,10 +196,9 @@ 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` — the canonical pinned identity shown in the connections panel: a service-account email for GCP, a role ARN for AWS. GCP impersonates it directly. AWS checks it as the expected role ARN for strict validity. -- `profile` — AWS single-account only. The `AWS_PROFILE` selector for the assume-role profile that produces credentials. Mutually exclusive with `accounts`; GCP ignores it. -- `source_profile` — AWS multi-account only. The operator's SSO base profile the generated per-account assume-role profiles layer their role over. Required when `accounts` is set. -- `accounts` — AWS multi-account only. The deployment-pinned account set the agent selects among via `set_active_target`; each entry is `{account_id, role_arn}`. See [Spanning several AWS accounts](#spanning-several-aws-accounts). This is the source-level selectable set, distinct from the informational `scope.accounts` note. +- `assumed_identity` — the canonical pinned identity shown in the connections panel: a service-account email for GCP, the default account's role ARN for AWS. GCP impersonates it directly. AWS validates the default account's resolved caller against it. +- `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}`, 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. - `scope` — the target allowlist (see below). - `command_allowlist_path` — an optional `run_cli` allowlist override (see below). Empty uses the provider's embedded default. diff --git a/docs/content/profiles.md b/docs/content/profiles.md index 68801b84..423f9e87 100644 --- a/docs/content/profiles.md +++ b/docs/content/profiles.md @@ -406,8 +406,10 @@ cloud: regions: [us-central1] - alias: prod-aws provider: aws - assumed_identity: arn:aws:iam::123456789012:role/triage-readonly # expected role ARN - profile: triage-readonly # AWS_PROFILE assume-role selector + assumed_identity: arn:aws:iam::123456789012:role/triage-readonly # default account's role ARN + source_profile: sso-admin # operator's SSO base profile + accounts: # one entry per account; single-account is a one-entry list + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly"} scope: accounts: ["123456789012"] regions: [eu-west-1] diff --git a/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index daeeb678..e3acbeb8 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -13,7 +13,6 @@ import ( "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/aws" "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" ) @@ -188,13 +187,12 @@ func kubeEnv(in mcpConfigInputs) map[string]string { // 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); single-account AWS selects -// the operator's assume-role profile (AWS_PROFILE) whose role_arn is the -// deployment's read-only role. A multi-account AWS source instead carries its -// accounts and source_profile (TRIAGENT_CLOUD_AWS_ACCOUNTS, _SOURCE_PROFILE): the -// subprocess generates a 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. +// 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) (map[string]string, error) { env := map[string]string{ cloud.EnvProvider: src.Provider, @@ -213,17 +211,13 @@ func cloudSourceEnv(src profile.CloudSource) (map[string]string, error) { case "gcp": env[gcp.EnvImpersonate] = src.AssumedIdentity case "aws": - if len(src.Accounts) > 0 { - 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 - } else { - env[aws.EnvProfile] = src.Profile + 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 } return env, nil } diff --git a/internal/preflight/mcpconfig_test.go b/internal/preflight/mcpconfig_test.go index ea89ee9a..cc396a3d 100644 --- a/internal/preflight/mcpconfig_test.go +++ b/internal/preflight/mcpconfig_test.go @@ -436,15 +436,18 @@ func TestWriteMCPConfig_GCPCloudSource_RegistersServerWithImpersonationEnv(t *te assert.Equal(t, []string{"prod-a"}, scope.Projects) } -func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithProfileAndExpectedRole(t *testing.T) { +func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithAccountsAndExpectedRole(t *testing.T) { t.Parallel() in := baseInputs(t) in.CloudSources = []profile.CloudSource{{ Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::123456789012:role/triage-ro", - Profile: "triage-ro", - Scope: cloud.ScopeAllowlist{Regions: []string{"us-east-1"}}, + 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) @@ -462,13 +465,13 @@ func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithProfileAndExpectedRole assert.Equal(t, "aws", env[cloud.EnvProvider]) // The pinned identity is uniform across providers. assert.Equal(t, "arn:aws:iam::123456789012:role/triage-ro", env[cloud.EnvExpectedIdentity]) - // aws selects an assume-role profile as its credential env. - assert.Equal(t, "triage-ro", env[aws.EnvProfile]) + // 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) - // The single-account form carries no accounts/source_profile env. - assert.NotContains(t, env, cloud.EnvAWSAccounts) - assert.NotContains(t, env, cloud.EnvAWSSourceProfile) } func TestCloudSourceEnv_AWSAccounts_EmitsAccountsAndSourceProfile(t *testing.T) { diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index e526437e..49206a7c 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -278,7 +278,6 @@ func cloudProbeSource(src profile.CloudSource) providers.Source { return providers.Source{ Provider: src.Provider, AssumedIdentity: src.AssumedIdentity, - Profile: src.Profile, Alias: src.Alias, SourceProfile: src.SourceProfile, Accounts: accounts, diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go index 316dee3e..fc61b067 100644 --- a/internal/preflight/preflight_test.go +++ b/internal/preflight/preflight_test.go @@ -239,7 +239,7 @@ func TestRun_CloudProbeFailureDegradesNotBlocks(t *testing.T) { prof := &profile.Profile{ Cloud: []profile.CloudSource{ {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com"}, - {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "ro"}, + {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, }, } res, err := Run(Options{ diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 78c35534..03d6d142 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -156,27 +156,25 @@ type ExtraMCP struct { // can read the pinned identity but cannot select or escalate it. // // AssumedIdentity is the canonical pinned identity shown in the connections -// panel — a service-account email for gcp, a role ARN for aws. The two clouds -// realize it through different env: gcp impersonates AssumedIdentity directly, -// while aws selects an assume-role profile for credentials and checks -// AssumedIdentity (the role ARN) for strict validity. +// panel — a service-account email for gcp, a role ARN for aws (the source's +// default account's role, which the panel probe validates against). // -// AWS has two shapes. The single-account form sets Profile (the operator's -// pre-existing AWS_PROFILE selector). The multi-account form sets SourceProfile -// (the operator's SSO base) plus Accounts (one read-only role per account); -// triagent generates a per-account assume-role profile layering each role over -// SourceProfile, and the agent selects among them via set_active_target. GCP -// spans its projects with one impersonated identity, so it ignores all three. +// GCP spans its projects with one impersonated identity (AssumedIdentity), +// selecting among them by Scope.Projects. AWS pins a list of accounts, each +// {account_id, role_arn}; triagent generates a per-account assume-role profile +// layering each role over SourceProfile, and the agent selects among them via +// set_active_target. 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 string `yaml:"assumed_identity"` - Profile string `yaml:"profile,omitempty"` // aws single-account AWS_PROFILE selector; ignored by gcp - // SourceProfile is the operator's SSO base profile the generated multi-account - // assume-role profiles layer their role_arn over. Required when Accounts is set. + Alias string `yaml:"alias"` + Provider string `yaml:"provider"` // "gcp" | "aws" + AssumedIdentity string `yaml:"assumed_identity"` + // 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 deployment-pinned multi-account set; each entry becomes a - // generated assume-role profile the agent may make active. aws-only. + // Accounts is the aws account set, each entry a {account_id, role_arn} 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"` Scope cloud.ScopeAllowlist `yaml:"scope,omitempty"` // CommandAllowlistPath points the cloud MCP at a run_cli allowlist override diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 2ebef0eb..1b677179 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -249,7 +249,7 @@ func TestValidateCloudSourcesOK(t *testing.T) { p := validCloudBase() p.Cloud = []profile.CloudSource{ {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@proj.iam.gserviceaccount.com"}, - {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "ro"}, + awsAccountsBase(), } assert.NoError(t, p.Validate(), "a valid multi-source cloud profile must validate clean") } @@ -258,7 +258,7 @@ 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", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "ro"}, + {Alias: "dup", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, } err := p.Validate() require.Error(t, err) @@ -297,14 +297,14 @@ func TestValidateCloudMissingIdentity(t *testing.T) { assert.Contains(t, err.Error(), "assumed_identity") } -func TestValidateCloudAWSMissingProfile(t *testing.T) { +func TestValidateCloudAWSMissingAccounts(t *testing.T) { p := validCloudBase() p.Cloud = []profile.CloudSource{ {Alias: "x", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro"}, } err := p.Validate() require.Error(t, err) - assert.Contains(t, err.Error(), "profile") + assert.Contains(t, err.Error(), "accounts") } const awsAccountsYAML = ` @@ -704,7 +704,9 @@ cloud: - alias: prod-aws provider: aws assumed_identity: arn:aws:iam::111122223333:role/ro - profile: ro + 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)) @@ -883,7 +885,9 @@ cloud: - alias: prod-aws provider: aws assumed_identity: arn:aws:iam::123456789012:role/triage-ro - profile: triage-ro + source_profile: sso-admin + accounts: + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-ro"} scope: regions: - us-east-1 @@ -903,6 +907,8 @@ cloud: assert.Equal(t, "prod-aws", aws.Alias) assert.Equal(t, "aws", aws.Provider) assert.Equal(t, "arn:aws:iam::123456789012:role/triage-ro", aws.AssumedIdentity) - assert.Equal(t, "triage-ro", aws.Profile) + 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{"us-east-1"}, aws.Scope.Regions) } diff --git a/internal/profile/validate.go b/internal/profile/validate.go index 1936e33d..ad86f670 100644 --- a/internal/profile/validate.go +++ b/internal/profile/validate.go @@ -95,27 +95,17 @@ func (p *Profile) Validate() error { return errors.New("profile " + p.Name + " invalid:\n - " + strings.Join(errs, "\n - ")) } -// validateAWSCredentials checks the two valid aws credential shapes. The -// multi-account form (accounts set) requires source_profile, at least one -// account, and non-empty account ids and role_arns that are each unique across -// the source. The single-account form (no accounts) requires the operator's -// pre-existing profile selector. The two are mutually exclusive: an accounts -// list pins its own profile per account, so a top-level profile alongside it is -// a misconfiguration. +// 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 { - if len(c.Accounts) == 0 { - if c.Profile == "" { - return []string{fmt.Sprintf("cloud[%d].profile: required when provider=aws (or set accounts + source_profile)", i)} - } - return nil - } - var errs []string - if c.Profile != "" { - errs = append(errs, fmt.Sprintf("cloud[%d].profile: must be empty when accounts is set (each account pins its own generated profile)", i)) + 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 accounts is set", i)) + errs = append(errs, fmt.Sprintf("cloud[%d].source_profile: required when provider=aws", i)) } seenIDs := map[string]bool{} seenARNs := map[string]bool{} diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go index db529d30..b62bc1c9 100644 --- a/internal/server/handlers_connections_test.go +++ b/internal/server/handlers_connections_test.go @@ -293,7 +293,7 @@ func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { prof := &profile.Profile{ Cloud: []profile.CloudSource{ {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com"}, - {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "ro"}, + {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, }, } a := &apiHandlers{ @@ -344,7 +344,7 @@ func TestGetConnections_DegradedSource_KeepsConfiguredIdentity(t *testing.T) { t.Parallel() prof := &profile.Profile{ Cloud: []profile.CloudSource{ - {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", Profile: "ro"}, + {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, }, } a := &apiHandlers{ diff --git a/pkg/mcp/cloud/fake_test.go b/pkg/mcp/cloud/fake_test.go index 364b0919..b5a35460 100644 --- a/pkg/mcp/cloud/fake_test.go +++ b/pkg/mcp/cloud/fake_test.go @@ -16,6 +16,7 @@ type fakeProvider struct { identityErr error envPassthrough []string targets []Target + expectedFor map[string]string } func (f *fakeProvider) Name() string { @@ -54,3 +55,8 @@ func (f *fakeProvider) Identity(context.Context, RunFunc, string) (IdentityStatu 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/probe_test.go b/pkg/mcp/cloud/probe_test.go index 8ecaf689..348de8f3 100644 --- a/pkg/mcp/cloud/probe_test.go +++ b/pkg/mcp/cloud/probe_test.go @@ -32,6 +32,7 @@ func (p *envProbeProvider) Inventory(context.Context, RunFunc) (Inventory, error 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 { diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go index c354af94..f1213d17 100644 --- a/pkg/mcp/cloud/provider.go +++ b/pkg/mcp/cloud/provider.go @@ -49,6 +49,13 @@ type Provider interface { // 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 diff --git a/pkg/mcp/cloud/providers/aws/inventory.go b/pkg/mcp/cloud/providers/aws/inventory.go index f5d5a9c6..93e6136c 100644 --- a/pkg/mcp/cloud/providers/aws/inventory.go +++ b/pkg/mcp/cloud/providers/aws/inventory.go @@ -2,111 +2,19 @@ package aws import ( "context" - "encoding/json" - "fmt" - "strings" "github.com/sourcehawk/triagent/pkg/mcp/cloud" ) -// listAccountsResult is the projection of `aws organizations list-accounts -// --output json`. Only the fields inventory needs are decoded. -type listAccountsResult struct { - Accounts []organizationsAccount `json:"Accounts"` -} - -type organizationsAccount struct { - ID string `json:"Id"` - Name string `json:"Name"` - Status string `json:"Status"` -} - -// Inventory projects the AWS accounts the pinned identity can read. -// -// When the source is configured with an accounts list, the reachable set is -// exactly those accounts: each is its own read-only role, so the configured set -// already describes what run_cli can reach. Inventory returns them directly and -// shells nothing — an org-wide list-accounts would over-advertise accounts the -// role cannot enter. -// -// Without a configured accounts list (the single-account form), the primary -// source is `aws organizations list-accounts`; only when that fails with an -// Organizations-unavailable condition (AccessDenied or the account not being a -// member of an organization) does it fall back to the single account the caller -// is in, derived from `aws sts get-caller-identity`. Any other failure — a -// transport error, throttling, a network fault — is surfaced rather than masked -// behind the single-account fallback. Both commands are allowlisted so the -// projection works under the validated run core. -func (p *Provider) Inventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { - if len(p.accounts) > 0 { - scopes := make([]cloud.Scope, 0, len(p.accounts)) - for _, a := range p.accounts { - scopes = append(scopes, cloud.Scope{ID: a.ID, Name: a.ID}) - } - return cloud.Inventory{Scopes: scopes}, nil - } - - res, err := run(ctx, []string{"organizations", "list-accounts", "--output", "json"}) - if err != nil { - return cloud.Inventory{}, fmt.Errorf("aws organizations list-accounts: %w", err) - } - if res.ExitCode != 0 { - if isOrgsUnavailable(res.Stderr) { - return p.callerAccountInventory(ctx, run) - } - return cloud.Inventory{}, fmt.Errorf("aws organizations list-accounts failed (exit %d): %s", res.ExitCode, res.Stderr) - } - - var parsed listAccountsResult - if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil { - return cloud.Inventory{}, fmt.Errorf("parse organizations list-accounts: %w", err) - } - - scopes := make([]cloud.Scope, 0, len(parsed.Accounts)) - for _, a := range parsed.Accounts { - if a.Status != "ACTIVE" { - continue - } - scopes = append(scopes, cloud.Scope{ID: a.ID, Name: a.Name}) +// 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}) } return cloud.Inventory{Scopes: scopes}, nil } - -// isOrgsUnavailable reports whether a non-zero `organizations list-accounts` -// stderr is the benign "Organizations is not available to this identity" -// condition — the only failure the single-account fallback is correct for. AWS -// returns AccessDenied when the role lacks Organizations permissions and -// AWSOrganizationsNotInUseException (or "not a member of an organization") when -// the account is standalone. -func isOrgsUnavailable(stderr string) bool { - for _, marker := range []string{ - "AccessDenied", - "not a member of an organization", - "AWSOrganizationsNotInUseException", - } { - if strings.Contains(stderr, marker) { - return true - } - } - return false -} - -// callerAccountInventory derives the single-account inventory from the caller -// identity, the fallback when Organizations access is denied. -func (p *Provider) callerAccountInventory(ctx context.Context, run cloud.RunFunc) (cloud.Inventory, error) { - res, err := run(ctx, []string{"sts", "get-caller-identity", "--output", "json"}) - if err != nil { - return cloud.Inventory{}, fmt.Errorf("caller identity for inventory fallback: %w", err) - } - if res.ExitCode != 0 { - return cloud.Inventory{}, fmt.Errorf("aws sts get-caller-identity failed (exit %d)", res.ExitCode) - } - var caller callerIdentity - if err := json.Unmarshal([]byte(res.Stdout), &caller); err != nil { - return cloud.Inventory{}, fmt.Errorf("parse caller identity for inventory fallback: %w", err) - } - if caller.Account == "" { - return cloud.Inventory{}, fmt.Errorf("caller identity has no account") - } - return cloud.Inventory{Scopes: []cloud.Scope{{ID: caller.Account, Name: caller.Account}}}, nil -} diff --git a/pkg/mcp/cloud/providers/aws/inventory_test.go b/pkg/mcp/cloud/providers/aws/inventory_test.go index 1225c910..8b9c72b3 100644 --- a/pkg/mcp/cloud/providers/aws/inventory_test.go +++ b/pkg/mcp/cloud/providers/aws/inventory_test.go @@ -9,113 +9,37 @@ import ( "github.com/stretchr/testify/require" ) -const listAccountsOutput = `{ - "Accounts": [ - { "Id": "111122223333", "Name": "prod", "Status": "ACTIVE" }, - { "Id": "444455556666", "Name": "staging", "Status": "ACTIVE" }, - { "Id": "777788889999", "Name": "suspended-acct", "Status": "SUSPENDED" } - ] -}` +// 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 a provider built with a configured -// accounts list reports exactly those accounts as the reachable set, without -// calling organizations list-accounts — the run func must never be invoked. +// 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"}, }) - failRun := func(_ context.Context, argv []string) (cloud.CLIResult, error) { - t.Fatalf("Inventory must not shell the CLI when accounts are configured; got %v", argv) - return cloud.CLIResult{}, nil - } - inv, err := p.Inventory(context.Background(), failRun) + 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]) } -func TestInventoryProjectsActiveAccounts(t *testing.T) { - f := &fakeRun{results: map[string]cloud.CLIResult{ - "organizations list-accounts": {Stdout: listAccountsOutput}, - }} - p, err := newWithBinary("/usr/bin/aws") - require.NoError(t, err) - - inv, err := p.Inventory(context.Background(), f.run) - require.NoError(t, err) - - require.Len(t, f.calls, 1) - assert.Equal(t, []string{"organizations", "list-accounts", "--output", "json"}, f.calls[0]) - - require.Len(t, inv.Scopes, 2, "suspended accounts are dropped") - assert.Equal(t, cloud.Scope{ID: "111122223333", Name: "prod"}, inv.Scopes[0]) - assert.Equal(t, cloud.Scope{ID: "444455556666", Name: "staging"}, inv.Scopes[1]) -} - -func TestInventoryFallsBackToCallerAccountOnAccessDenied(t *testing.T) { - f := &fakeRun{ - results: map[string]cloud.CLIResult{ - "organizations list-accounts": {ExitCode: 254, Stderr: "An error occurred (AccessDeniedException) when calling the ListAccounts operation: ..."}, - "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, - }, - } - p, err := newWithBinary("/usr/bin/aws") - require.NoError(t, err) - - inv, err := p.Inventory(context.Background(), f.run) - require.NoError(t, err) - - require.Len(t, inv.Scopes, 1, "no orgs access falls back to the single caller account") - assert.Equal(t, "111122223333", inv.Scopes[0].ID) -} - -func TestInventoryFallsBackWhenOrgsNotInUse(t *testing.T) { - f := &fakeRun{ - results: map[string]cloud.CLIResult{ - "organizations list-accounts": {ExitCode: 254, Stderr: "An error occurred (AWSOrganizationsNotInUseException) when calling the ListAccounts operation"}, - "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, - }, - } - p, err := newWithBinary("/usr/bin/aws") - require.NoError(t, err) - - inv, err := p.Inventory(context.Background(), f.run) +// 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, "111122223333", inv.Scopes[0].ID) -} - -func TestInventorySurfacesNonAccessDeniedFailure(t *testing.T) { - f := &fakeRun{ - results: map[string]cloud.CLIResult{ - "organizations list-accounts": {ExitCode: 254, Stderr: "An error occurred (ThrottlingException) when calling the ListAccounts operation: Rate exceeded"}, - "sts get-caller-identity": {Stdout: callerIdentityAssumedRole}, - }, - } - p, err := newWithBinary("/usr/bin/aws") - require.NoError(t, err) - - _, err = p.Inventory(context.Background(), f.run) - require.Error(t, err, "a throttling failure must surface, not silently fall back") - assert.Contains(t, err.Error(), "ThrottlingException", "the error surfaces the captured stderr") - // The fallback caller-identity call must not have run. - for _, c := range f.calls { - assert.NotEqual(t, []string{"sts", "get-caller-identity", "--output", "json"}, c, - "a non-AccessDenied failure must not trigger the single-account fallback") - } -} - -func TestInventorySurfacesTransportError(t *testing.T) { - f := &fakeRun{ - errs: map[string]error{ - "organizations list-accounts": errAccessDenied, - }, - } - p, err := newWithBinary("/usr/bin/aws") - require.NoError(t, err) - - _, err = p.Inventory(context.Background(), f.run) - require.Error(t, err, "a process that could not be run is a transport failure, surfaced not masked") + assert.Equal(t, cloud.Scope{ID: "111111111111", Name: "111111111111"}, inv.Scopes[0]) } diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go index 1d2ab91a..7ee35a9d 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -100,7 +100,7 @@ func New(opts ...Options) (*Provider, error) { // 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; the zero value is the single-account legacy form. +// one Options is honored. func newWithBinary(binary string, opts ...Options) (*Provider, error) { var list cloud.CommandAllowlist if err := json.Unmarshal(defaultCommandsJSON, &list); err != nil { @@ -163,15 +163,11 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { } } -// ConfiguredTargets is the deployment-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. The single-account deployment -// carries no accounts list and returns nil, so the selectable set comes from the -// server's inventory instead. +// 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 { - if len(p.accounts) == 0 { - return nil - } out := make([]cloud.Target, 0, len(p.accounts)) for _, a := range p.accounts { out = append(out, cloud.Target{ID: a.ID, Name: a.ID}) @@ -179,19 +175,27 @@ func (p *Provider) ConfiguredTargets() []cloud.Target { return out } -// ActiveTargetEnv pins the aws CLI to the active account via AWS_PROFILE. For a -// configured account it names the generated assume-role profile -// (triagent-cloud--); the single-account legacy form passes -// the id through as the profile name directly. Either way the value is a profile -// name, not a credential: the CLI performs the assume-role from the operator's -// base. +// 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 { - if len(p.accounts) == 0 { - return []string{EnvProfile + "=" + id} - } 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 diff --git a/pkg/mcp/cloud/providers/aws/provider_test.go b/pkg/mcp/cloud/providers/aws/provider_test.go index 4ed23361..39819fd3 100644 --- a/pkg/mcp/cloud/providers/aws/provider_test.go +++ b/pkg/mcp/cloud/providers/aws/provider_test.go @@ -3,7 +3,6 @@ package aws import ( "context" "encoding/json" - "errors" "os" "path/filepath" "strings" @@ -59,9 +58,9 @@ func TestDefaultAllowlistCoversReadOnlyAxes(t *testing.T) { // 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 (identity + inventory fallback)") + "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 (inventory primary)") + "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{ @@ -205,12 +204,10 @@ func keyOf(argv []string) string { return out } -var errAccessDenied = errors.New("access denied (AccessDeniedException) when calling the ListAccounts operation") - -func TestConfiguredTargetsEmptyForSingleAccount(t *testing.T) { +func TestConfiguredTargetsEmptyWithoutAccounts(t *testing.T) { p, err := newWithBinary("/usr/bin/aws") require.NoError(t, err) - assert.Nil(t, p.ConfiguredTargets()) + assert.Empty(t, p.ConfiguredTargets()) } func TestConfiguredTargetsFromAccounts(t *testing.T) { @@ -232,13 +229,17 @@ func TestActiveTargetEnvUsesGeneratedProfileName(t *testing.T) { assert.Equal(t, []string{"AWS_PROFILE=triagent-cloud-prod-aws-111111111111"}, p.ActiveTargetEnv("111111111111")) } -// TestActiveTargetEnvSingleAccountPassthrough proves the legacy single-account -// provider (no alias, no accounts) treats the active id as the profile name -// directly, reproducing today's AWS_PROFILE= behavior. -func TestActiveTargetEnvSingleAccountPassthrough(t *testing.T) { - p, err := newWithBinary("/usr/bin/aws") - require.NoError(t, err) - assert.Equal(t, []string{"AWS_PROFILE=ro"}, p.ActiveTargetEnv("ro")) +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 with a generated-profile config diff --git a/pkg/mcp/cloud/providers/gcp/provider.go b/pkg/mcp/cloud/providers/gcp/provider.go index 23d78755..1db975b0 100644 --- a/pkg/mcp/cloud/providers/gcp/provider.go +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -109,6 +109,11 @@ 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. diff --git a/pkg/mcp/cloud/providers/probe.go b/pkg/mcp/cloud/providers/probe.go index 4e874cff..f2ed13b7 100644 --- a/pkg/mcp/cloud/providers/probe.go +++ b/pkg/mcp/cloud/providers/probe.go @@ -31,18 +31,16 @@ var baseEnvPassthrough = []string{"PATH", "HOME"} // what ProbeSource needs without coupling this package to the launcher's profile // type. // -// AWS has two forms. The single-account form sets Profile (the operator's -// AWS_PROFILE selector). The multi-account form sets Alias, SourceProfile, and -// Accounts; ProbeSource generates the per-account profiles and probes the default -// (first) account's generated profile — per-account validity is out of scope for -// v1, so the panel reflects the source's default-target validity. gcp ignores -// all four. +// 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 - Profile string // aws single-account AWS_PROFILE selector; ignored by gcp - Alias string // aws multi-account: the generated profiles' namespace - SourceProfile string // aws multi-account: the operator's SSO base profile + Alias string // aws: the generated profiles' namespace + SourceProfile string // aws: the operator's SSO base profile Accounts []aws.Account } @@ -139,11 +137,11 @@ func credentialEnv(src Source) map[string]string { } // awsProbeProfile is the AWS_PROFILE the launcher-side probe authenticates with: -// the default (first) account's generated profile for a multi-account source, -// else the operator's single-account profile selector. +// 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 aws.ProfileName(src.Alias, src.Accounts[0].ID) + if len(src.Accounts) == 0 { + return "" } - return src.Profile + 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 index 36ab890d..83d2624d 100644 --- a/pkg/mcp/cloud/providers/probe_test.go +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -3,6 +3,7 @@ package providers import ( "context" "os" + "path/filepath" "testing" "time" @@ -26,8 +27,9 @@ 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) 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() @@ -60,13 +62,14 @@ func TestProbeProviderBoundsHungCLI(t *testing.T) { 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", AssumedIdentity: "arn:aws:iam::111122223333:role/triage-ro", Profile: "triage-ro"}, + {Provider: "aws", AssumedIdentity: "arn:aws:iam::111122223333:role/triage-ro", Alias: "probe-aws", SourceProfile: "triage-ro", Accounts: []aws.Account{{ID: "111122223333", RoleARN: "arn:aws:iam::111122223333:role/triage-ro"}}}, } { _ = ProbeSource(context.Background(), src) } @@ -101,11 +104,11 @@ func TestProbeSourceConstructionFailureKeepsPinnedIdentity(t *testing.T) { assert.NotEmpty(t, st.Hint) } -// TestCredentialEnvAWSMultiAccountTargetsDefaultProfile proves the launcher-side -// probe for a multi-account aws source pins AWS_PROFILE to the default (first) -// account's generated profile name, not the operator's raw profile. Per-account -// validity is out of scope for v1; the panel shows the default target's validity. -func TestCredentialEnvAWSMultiAccountTargetsDefaultProfile(t *testing.T) { +// 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", @@ -116,12 +119,7 @@ func TestCredentialEnvAWSMultiAccountTargetsDefaultProfile(t *testing.T) { }, }) assert.Equal(t, "triagent-cloud-prod-aws-111111111111", env[aws.EnvProfile], - "the multi-account probe must target the default account's generated profile") -} - -func TestCredentialEnvAWSSingleAccountUsesProfile(t *testing.T) { - env := credentialEnv(Source{Provider: "aws", Profile: "triage-ro"}) - assert.Equal(t, "triage-ro", env[aws.EnvProfile]) + "the probe must target the default account's generated profile") } // fakePassthroughProvider exposes a fixed EnvPassthrough so sourceEnv's diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index 526f133d..a2fbd3d5 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -167,6 +167,20 @@ func (s *Server) runValidated(ctx context.Context, argv []string) (CLIResult, er // 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 diff --git a/pkg/mcp/cloud/server_test.go b/pkg/mcp/cloud/server_test.go index 7784b201..05e7c1f0 100644 --- a/pkg/mcp/cloud/server_test.go +++ b/pkg/mcp/cloud/server_test.go @@ -92,6 +92,37 @@ func TestSetActiveTargetRejectsOutOfSet(t *testing.T) { 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"}}}) diff --git a/pkg/mcp/cloud/tools_status.go b/pkg/mcp/cloud/tools_status.go index 60d600c7..c78c22bf 100644 --- a/pkg/mcp/cloud/tools_status.go +++ b/pkg/mcp/cloud/tools_status.go @@ -20,7 +20,7 @@ type SessionStatusOutput = IdentityStatus // 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.expectedIdentity, s.subprocessEnv()) + st, err := Probe(ctx, s.provider, s.expectedIdentityForActive(), s.subprocessEnv()) if err != nil { return errorResult(err.Error()), SessionStatusOutput{}, nil } diff --git a/pkg/mcp/cloud/tools_target.go b/pkg/mcp/cloud/tools_target.go index 87d477b3..8b86c4d4 100644 --- a/pkg/mcp/cloud/tools_target.go +++ b/pkg/mcp/cloud/tools_target.go @@ -25,7 +25,7 @@ func (s *Server) setActiveTarget(ctx context.Context, _ *mcp.CallToolRequest, in 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.expectedIdentity, s.subprocessEnv()) + st, _ := Probe(ctx, s.provider, s.expectedIdentityForActive(), s.subprocessEnv()) st.ActiveTarget = s.activeTarget return nil, st, nil } From 9a56d0f98ae41e28e274ff933ec68fb6e831039e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 21:54:30 +0200 Subject: [PATCH 43/76] fix(mcp/cloud): ungate list_inventory and let the active target win the env list_inventory shelled through s.run, whose active-target gate would fail a multi-target GCP source with "call set_active_target" before the agent could see which targets exist to choose from; it now uses the ungated runValidated path, like selectableTargets. And subprocessEnv appended the active-target env after the passthrough set, leaving a duplicate (e.g. an ambient AWS_PROFILE / CLOUDSDK_CORE_PROJECT) an exec reader could resolve to the ambient target; the ambient entry is now dropped before the MCP-controlled value is appended. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/server.go | 36 +++++++++++++++++++++++- pkg/mcp/cloud/server_test.go | 48 ++++++++++++++++++++++++++++++++ pkg/mcp/cloud/tools_inventory.go | 8 ++++-- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index a2fbd3d5..ea61f0c3 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -185,14 +185,48 @@ func (s *Server) expectedIdentityForActive() string { // 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 != "" { - env = append(env, s.provider.ActiveTargetEnv(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 diff --git a/pkg/mcp/cloud/server_test.go b/pkg/mcp/cloud/server_test.go index 05e7c1f0..29ef0c2f 100644 --- a/pkg/mcp/cloud/server_test.go +++ b/pkg/mcp/cloud/server_test.go @@ -2,6 +2,7 @@ package cloud import ( "context" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -84,6 +85,53 @@ func TestSelectableTargetsInventoryDoesNotRecurse(t *testing.T) { 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"}}}) diff --git a/pkg/mcp/cloud/tools_inventory.go b/pkg/mcp/cloud/tools_inventory.go index 7d3cdfc4..b5e7bb90 100644 --- a/pkg/mcp/cloud/tools_inventory.go +++ b/pkg/mcp/cloud/tools_inventory.go @@ -17,10 +17,12 @@ type ListInventoryInput struct{} // accessible scopes. type ListInventoryOutput = Inventory -// listInventory projects the provider's accessible scopes. The provider execs -// only through the server's validated run core. +// 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.run) + inv, err := s.provider.Inventory(ctx, s.runValidated) if err != nil { return errorResult(fmt.Sprintf("list inventory: %v", err)), ListInventoryOutput{}, nil } From 4056d82be2c4097a3d8ebbfd65a93375bdabac82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 21:54:30 +0200 Subject: [PATCH 44/76] fix(mcp/cloud): serialize AWS managed-profile generation against concurrent writers writeManagedProfiles was an unlocked read-modify-write of ~/.aws/config: two AWS sources, or a launcher probe and a serve subprocess, could each read the same base and have the last rename drop the other alias's managed block (and race on a shared .tmp). Guard it with a process mutex plus an advisory file lock (flock on unix via x/sys; a no-op on Windows, where the mutex still covers same-process generation) and a per-process tmp file. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- pkg/mcp/cloud/providers/aws/flock_unix.go | 18 ++++++++ pkg/mcp/cloud/providers/aws/flock_windows.go | 13 ++++++ pkg/mcp/cloud/providers/aws/profiles.go | 43 +++++++++++++++++++- pkg/mcp/cloud/providers/aws/profiles_test.go | 32 +++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 pkg/mcp/cloud/providers/aws/flock_unix.go create mode 100644 pkg/mcp/cloud/providers/aws/flock_windows.go 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/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/profiles.go b/pkg/mcp/cloud/providers/aws/profiles.go index 5d0aca0e..16c23658 100644 --- a/pkg/mcp/cloud/providers/aws/profiles.go +++ b/pkg/mcp/cloud/providers/aws/profiles.go @@ -4,9 +4,18 @@ 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 @@ -50,6 +59,18 @@ func writeManagedProfiles(configPath, alias, sourceProfile string, accounts []Ac block.WriteString(end) block.WriteString("\n") + managedProfilesMu.Lock() + defer managedProfilesMu.Unlock() + + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + return fmt.Errorf("aws: create config dir for %s: %w", configPath, err) + } + release, err := acquireConfigLock(configPath) + if err != nil { + return err + } + defer release() + existing, err := os.ReadFile(configPath) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("aws: read config %s: %w", configPath, err) @@ -58,6 +79,26 @@ func writeManagedProfiles(configPath, alias, sourceProfile string, accounts []Ac return atomicWrite(configPath, []byte(merged)) } +// 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 @@ -90,7 +131,7 @@ 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" + 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) } diff --git a/pkg/mcp/cloud/providers/aws/profiles_test.go b/pkg/mcp/cloud/providers/aws/profiles_test.go index e8c0b53b..3c7febab 100644 --- a/pkg/mcp/cloud/providers/aws/profiles_test.go +++ b/pkg/mcp/cloud/providers/aws/profiles_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -90,3 +91,34 @@ func TestWriteManagedProfilesTwoAliases(t *testing.T) { assert.Equal(t, 1, strings.Count(got, "# BEGIN triagent-cloud-prod-aws")) assert.Equal(t, 1, strings.Count(got, "# BEGIN triagent-cloud-staging-aws")) } + +// TestWriteManagedProfilesConcurrentAliasesSurvive pins that concurrent +// generation for different aliases into the same config file does not drop a +// block: the read-modify-write is serialized, so every alias's managed section +// is present afterward. Without the lock, racing read-modify-writes clobber each +// other and a block goes missing. +func TestWriteManagedProfilesConcurrentAliasesSurvive(t *testing.T) { + cfg := filepath.Join(t.TempDir(), "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() + err := writeManagedProfiles(cfg, a, "sso-admin", []Account{ + {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/" + a}, + }) + assert.NoError(t, err) + }(alias) + } + wg.Wait() + + data, err := os.ReadFile(cfg) + require.NoError(t, err) + got := string(data) + for _, a := range aliases { + begin, _ := blockMarkers(a) + assert.Contains(t, got, begin, "alias %q block must survive concurrent generation", a) + } +} From 03c1c9dfe5087927aed928f177e7cd33ed704bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 21:54:30 +0200 Subject: [PATCH 45/76] fix(profile): default profile.yaml AWS example uses the accounts model The commented cloud example still showed the removed `profile:` field, which now fails validation (aws requires source_profile + accounts). Update it to a one-entry accounts list so copying it configures a working source. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../profile/profiles/default/profile.yaml | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/profile/profiles/default/profile.yaml b/internal/profile/profiles/default/profile.yaml index b6617a0a..971d7b8a 100644 --- a/internal/profile/profiles/default/profile.yaml +++ b/internal/profile/profiles/default/profile.yaml @@ -197,16 +197,16 @@ extra_mcps: [] # 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 selects `profile` (an AWS_PROFILE whose -# role_arn is the read-only role, source_profile the operator's base) and -# checks the resolved caller against `assumed_identity` (the role ARN). -# `scope` is the projects/accounts/regions allowlist any command may -# reference; an empty axis is unconstrained. `command_allowlist_path` -# overrides the provider's embedded read-only default. +# `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` is the projects/accounts/regions allowlist +# any command may reference; an empty axis is unconstrained. +# `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 configure the -# aws assume-role profile. See: +# 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: @@ -218,8 +218,10 @@ extra_mcps: [] # regions: [us-central1] # - alias: prod-aws # provider: aws -# assumed_identity: arn:aws:iam::123456789012:role/triage-readonly -# profile: triage-readonly +# assumed_identity: arn:aws:iam::123456789012:role/triage-readonly # default account's role +# 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"} # scope: # accounts: ["123456789012"] # regions: [eu-west-1] From c72d72bb02880a2f35716044f38a313c1b7ce873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 23:49:45 +0200 Subject: [PATCH 46/76] refactor(cloud): drop assumed_identity for AWS; provider-specific connections pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assumed_identity made sense only for gcp (one impersonated service account spans every project). For aws it was a redundant source-level mirror of accounts[0]. role_arn that had to be kept in sync or the panel would falsely show the default account invalid. Make it gcp-only: validation rejects it on an aws source, the launcher/probe derive aws's expected identity per account (the default account's role_arn for the panel, each active account's own role_arn in-session), and the connections DTO carries aws's account set + source_profile instead. The connections pill now branches by provider: gcp shows the impersonated service account; aws shows "N accounts · base: " with the account ids in the hover title — no false single 'we're assuming this' line for a multi-account source. Docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 11 +-- docs/content/profiles.md | 5 +- frontend/components/ConnectionsPanel.test.tsx | 34 ++++++++- frontend/components/ConnectionsPanel.tsx | 30 +++++++- frontend/lib/api.ts | 13 +++- internal/preflight/mcpconfig.go | 14 +++- internal/preflight/mcpconfig_test.go | 17 ++--- internal/preflight/preflight_test.go | 16 ++-- internal/profile/profile.go | 26 +++---- internal/profile/profile_test.go | 27 ++++--- .../profile/profiles/default/profile.yaml | 3 +- internal/profile/validate.go | 25 ++++--- internal/server/handlers_connections.go | 57 +++++++++----- internal/server/handlers_connections_test.go | 74 ++++++++++++------- pkg/mcp/cloud/providers/probe.go | 26 +++++-- pkg/mcp/cloud/providers/probe_test.go | 2 +- 16 files changed, 251 insertions(+), 129 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index c28b22b8..b3f6a957 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -62,13 +62,12 @@ An AWS role lives in exactly one account, so a source names a list of `accounts` cloud: - alias: prod-aws provider: aws - assumed_identity: arn:aws:iam::123456789012:role/triage-readonly # the default account's role source_profile: default # the operator's SSO base accounts: - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly"} ``` -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. `assumed_identity` is the default (first) account's role ARN, which the connections panel validates against. +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: @@ -128,7 +127,6 @@ An IAM role lives in exactly one account, so reaching several accounts is just a cloud: - alias: prod-aws provider: aws - assumed_identity: arn:aws:iam::111111111111:role/triage-readonly source_profile: sso-admin # the operator's SSO base profile accounts: - {account_id: "111111111111", role_arn: "arn:aws:iam::111111111111:role/triage-readonly"} @@ -177,9 +175,8 @@ cloud: - alias: prod-aws provider: aws - # For aws, the default account's role ARN, shown in the connections panel and - # validated by its probe. - assumed_identity: arn:aws:iam::123456789012:role/triage-readonly + # 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 @@ -196,7 +193,7 @@ 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` — the canonical pinned identity shown in the connections panel: a service-account email for GCP, the default account's role ARN for AWS. GCP impersonates it directly. AWS validates the default account's resolved caller against it. +- `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}`, 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. - `scope` — the target allowlist (see below). diff --git a/docs/content/profiles.md b/docs/content/profiles.md index 423f9e87..2a275f27 100644 --- a/docs/content/profiles.md +++ b/docs/content/profiles.md @@ -394,7 +394,7 @@ 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 (a service-account email for GCP, a role ARN for AWS) that the agent can read but never select or escalate, with a `scope` allowlist constraining which projects / accounts / regions any command may reference. +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`) — with a `scope` allowlist constraining which projects / accounts / regions any command may reference. ```yaml cloud: @@ -405,8 +405,7 @@ cloud: projects: [prod-platform] regions: [us-central1] - alias: prod-aws - provider: aws - assumed_identity: arn:aws:iam::123456789012:role/triage-readonly # default account's role ARN + provider: aws # aws has no assumed_identity; identity is per-account source_profile: sso-admin # operator's SSO base profile accounts: # one entry per account; single-account is a one-entry list - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly"} diff --git a/frontend/components/ConnectionsPanel.test.tsx b/frontend/components/ConnectionsPanel.test.tsx index 1789ee01..42f0aa62 100644 --- a/frontend/components/ConnectionsPanel.test.tsx +++ b/frontend/components/ConnectionsPanel.test.tsx @@ -30,7 +30,7 @@ describe("ConnectionsPanel cloud pills", () => { vi.restoreAllMocks(); }); - it("renders a read-only cloud pill per entry with the alias and assumed identity", async () => { + it("shows the assumed identity for gcp and the account set for aws", async () => { vi.spyOn(api, "getConnections").mockResolvedValue({ ...baseStatus, cloud: [ @@ -43,7 +43,8 @@ describe("ConnectionsPanel cloud pills", () => { { alias: "prod-aws", provider: "aws", - assumed_identity: "arn:aws:iam::1:role/triage-ro", + accounts: ["111111111111", "222222222222"], + source_profile: "sso-admin", valid: false, hint: "run: aws sso login", }, @@ -54,11 +55,35 @@ describe("ConnectionsPanel cloud pills", () => { expect(await screen.findByText("prod-gcp")).toBeInTheDocument(); expect(screen.getByText("prod-aws")).toBeInTheDocument(); + // gcp: the one impersonated service account. expect( screen.getByText("triage-ro@prod.iam.gserviceaccount.com"), ).toBeInTheDocument(); + // aws: the account-set summary, never a single assumed identity. The full + // account ids live in the hover title. + const awsBody = screen.getByText("2 accounts · base: sso-admin"); + expect(awsBody).toBeInTheDocument(); + expect(awsBody).toHaveAttribute("title", "111111111111, 222222222222"); + }); + + it("singularizes a one-account aws source", async () => { + vi.spyOn(api, "getConnections").mockResolvedValue({ + ...baseStatus, + cloud: [ + { + alias: "prod-aws", + provider: "aws", + accounts: ["123456789012"], + source_profile: "sso-admin", + valid: true, + }, + ], + }); + + await renderPanelAndOpenModal(); + expect( - screen.getByText("arn:aws:iam::1:role/triage-ro"), + await screen.findByText("1 account · base: sso-admin"), ).toBeInTheDocument(); }); @@ -69,7 +94,8 @@ describe("ConnectionsPanel cloud pills", () => { { alias: "prod-aws", provider: "aws", - assumed_identity: "arn:aws:iam::1:role/triage-ro", + accounts: ["111111111111"], + source_profile: "sso-admin", valid: false, hint: "run: aws sso login", }, diff --git a/frontend/components/ConnectionsPanel.tsx b/frontend/components/ConnectionsPanel.tsx index a2d40b03..21dcf739 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/ConnectionsPanel.tsx @@ -262,9 +262,7 @@ function CloudPill({ conn }: { conn: CloudConnection }) { )} -
- {conn.assumed_identity} -
+ {!conn.valid && conn.hint && (
{conn.hint}
)} @@ -272,6 +270,32 @@ function CloudPill({ conn }: { conn: CloudConnection }) { ); } +// CloudPillBody renders the identity line, which differs by provider. GCP shows +// its one impersonated service account. AWS has no single assumed identity, so +// it shows the account set it spans and the operator's SSO base profile, with +// the full account ids in the hover title. +function CloudPillBody({ conn }: { conn: CloudConnection }) { + if (conn.provider === "aws") { + const accounts = conn.accounts ?? []; + const n = accounts.length; + const base = conn.source_profile ? ` · base: ${conn.source_profile}` : ""; + return ( +
+ {n} account{n === 1 ? "" : "s"} + {base} +
+ ); + } + return ( +
+ {conn.assumed_identity} +
+ ); +} + type ConnectionCardProps = { label: string; icon: React.ReactNode; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 092a7158..8235dc7d 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -96,13 +96,18 @@ export type ConnectionStatus = { }; // 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. +// triagent-cloud- MCP and the request-time identity-probe result. valid +// drives the checkmark; hint is the reauth advice shown when the probe failed. +// +// The identity shape is provider-specific. gcp carries one assumed_identity (the +// impersonated service account). aws has no single assumed identity: it carries +// the accounts it spans and the operator's source_profile (the SSO base). export type CloudConnection = { alias: string; provider: string; - assumed_identity: string; + assumed_identity?: string; + accounts?: string[]; + source_profile?: string; valid: boolean; hint?: string; }; diff --git a/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index e3acbeb8..417b9aa5 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -195,8 +195,18 @@ func kubeEnv(in mcpConfigInputs) map[string]string { // literals. func cloudSourceEnv(src profile.CloudSource) (map[string]string, error) { env := map[string]string{ - cloud.EnvProvider: src.Provider, - cloud.EnvExpectedIdentity: src.AssumedIdentity, + 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 diff --git a/internal/preflight/mcpconfig_test.go b/internal/preflight/mcpconfig_test.go index cc396a3d..b5cbc375 100644 --- a/internal/preflight/mcpconfig_test.go +++ b/internal/preflight/mcpconfig_test.go @@ -440,10 +440,9 @@ func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithAccountsAndExpectedRol t.Parallel() in := baseInputs(t) in.CloudSources = []profile.CloudSource{{ - Alias: "prod-aws", - Provider: "aws", - AssumedIdentity: "arn:aws:iam::123456789012:role/triage-ro", - SourceProfile: "sso-admin", + Alias: "prod-aws", + Provider: "aws", + SourceProfile: "sso-admin", Accounts: []profile.CloudAccount{ {AccountID: "123456789012", RoleARN: "arn:aws:iam::123456789012:role/triage-ro"}, }, @@ -463,7 +462,8 @@ func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithAccountsAndExpectedRol env, _ := srv["env"].(map[string]any) require.NotNil(t, env) assert.Equal(t, "aws", env[cloud.EnvProvider]) - // The pinned identity is uniform across providers. + // 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. @@ -477,10 +477,9 @@ func TestWriteMCPConfig_AWSCloudSource_RegistersServerWithAccountsAndExpectedRol func TestCloudSourceEnv_AWSAccounts_EmitsAccountsAndSourceProfile(t *testing.T) { t.Parallel() env, err := cloudSourceEnv(profile.CloudSource{ - Alias: "prod-aws", - Provider: "aws", - AssumedIdentity: "arn:aws:iam::111111111111:role/triage-ro", - SourceProfile: "sso-admin", + 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"}, diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go index fc61b067..b17a9b3f 100644 --- a/internal/preflight/preflight_test.go +++ b/internal/preflight/preflight_test.go @@ -239,7 +239,7 @@ func TestRun_CloudProbeFailureDegradesNotBlocks(t *testing.T) { prof := &profile.Profile{ Cloud: []profile.CloudSource{ {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com"}, - {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, + {Alias: "prod-aws", Provider: "aws", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, }, } res, err := Run(Options{ @@ -316,10 +316,9 @@ func TestRun_CloudProviderConstructionErrorDegrades(t *testing.T) { func TestCloudProbeSource_ThreadsAWSMultiAccountFields(t *testing.T) { t.Parallel() src := profile.CloudSource{ - Alias: "prod-aws", - Provider: "aws", - AssumedIdentity: "arn:aws:iam::111111111111:role/triage-ro", - SourceProfile: "sso-base", + 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"}, @@ -329,10 +328,9 @@ func TestCloudProbeSource_ThreadsAWSMultiAccountFields(t *testing.T) { got := cloudProbeSource(src) assert.Equal(t, providers.Source{ - Provider: "aws", - AssumedIdentity: "arn:aws:iam::111111111111:role/triage-ro", - Alias: "prod-aws", - SourceProfile: "sso-base", + 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"}, diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 03d6d142..87b41e36 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -155,20 +155,20 @@ type ExtraMCP struct { // configured in the profile, never entered in the connections panel: the agent // can read the pinned identity but cannot select or escalate it. // -// AssumedIdentity is the canonical pinned identity shown in the connections -// panel — a service-account email for gcp, a role ARN for aws (the source's -// default account's role, which the panel probe validates against). -// -// GCP spans its projects with one impersonated identity (AssumedIdentity), -// selecting among them by Scope.Projects. AWS pins a list of accounts, each -// {account_id, role_arn}; triagent generates a per-account assume-role profile -// layering each role over SourceProfile, and the agent selects among them via -// set_active_target. A single-account AWS source is simply a one-entry Accounts -// list — there is no separate single-account shape. +// 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 string `yaml:"assumed_identity"` + 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"` diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 1b677179..6491a2f0 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -258,7 +258,7 @@ 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", AssumedIdentity: "arn:aws:iam::1:role/ro", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, + {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) @@ -300,13 +300,24 @@ func TestValidateCloudMissingIdentity(t *testing.T) { func TestValidateCloudAWSMissingAccounts(t *testing.T) { p := validCloudBase() p.Cloud = []profile.CloudSource{ - {Alias: "x", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro"}, + {Alias: "x", Provider: "aws", SourceProfile: "sso"}, } err := p.Validate() require.Error(t, err) assert.Contains(t, err.Error(), "accounts") } +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 @@ -318,7 +329,6 @@ playbooks: cloud: - alias: prod-aws provider: aws - assumed_identity: arn:aws:iam::111111111111:role/triage-readonly source_profile: sso-admin accounts: - {account_id: "111111111111", role_arn: "arn:aws:iam::111111111111:role/triage-readonly"} @@ -339,10 +349,9 @@ func TestCloudSourceAWSAccounts(t *testing.T) { // each break in exactly one way. func awsAccountsBase() profile.CloudSource { return profile.CloudSource{ - Alias: "prod-aws", - Provider: "aws", - AssumedIdentity: "arn:aws:iam::111111111111:role/triage-readonly", - SourceProfile: "sso-admin", + 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"}, @@ -703,7 +712,6 @@ cloud: command_allowlist_path: allow/gcp.json - alias: prod-aws provider: aws - assumed_identity: arn:aws:iam::111122223333:role/ro source_profile: sso-admin accounts: - {account_id: "111122223333", role_arn: "arn:aws:iam::111122223333:role/ro"} @@ -884,7 +892,6 @@ cloud: command_allowlist_path: /etc/triagent/gcp-allow.json - alias: prod-aws provider: aws - assumed_identity: arn:aws:iam::123456789012:role/triage-ro source_profile: sso-admin accounts: - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-ro"} @@ -906,7 +913,7 @@ cloud: aws := p.Cloud[1] assert.Equal(t, "prod-aws", aws.Alias) assert.Equal(t, "aws", aws.Provider) - assert.Equal(t, "arn:aws:iam::123456789012:role/triage-ro", aws.AssumedIdentity) + 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) diff --git a/internal/profile/profiles/default/profile.yaml b/internal/profile/profiles/default/profile.yaml index 971d7b8a..84538f65 100644 --- a/internal/profile/profiles/default/profile.yaml +++ b/internal/profile/profiles/default/profile.yaml @@ -217,8 +217,7 @@ extra_mcps: [] # projects: [prod-platform] # regions: [us-central1] # - alias: prod-aws -# provider: aws -# assumed_identity: arn:aws:iam::123456789012:role/triage-readonly # default account's role +# 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"} diff --git a/internal/profile/validate.go b/internal/profile/validate.go index ad86f670..1ffb42cb 100644 --- a/internal/profile/validate.go +++ b/internal/profile/validate.go @@ -61,9 +61,11 @@ 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, missing identity, or aws source - // without a profile reaches preflight as a broken connection. Catch all of - // it here. + // 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 == "" { @@ -74,19 +76,20 @@ func (p *Profile) Validate() error { seenAliases[c.Alias] = true switch c.Provider { - case "gcp", "aws": + case "gcp": + if c.AssumedIdentity == "" { + errs = append(errs, fmt.Sprintf("cloud[%d].assumed_identity: required when provider=gcp", i)) + } + 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)) + } + 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 c.AssumedIdentity == "" { - errs = append(errs, fmt.Sprintf("cloud[%d].assumed_identity: required", i)) - } - if c.Provider == "aws" { - errs = append(errs, validateAWSCredentials(i, c)...) - } } if len(errs) == 0 { diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index c775ea88..9f84a63a 100644 --- a/internal/server/handlers_connections.go +++ b/internal/server/handlers_connections.go @@ -37,11 +37,18 @@ type connectionsResponse struct { // 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"` - Hint string `json:"hint,omitempty"` + Alias string `json:"alias"` + Provider string `json:"provider"` + // AssumedIdentity is the gcp impersonated service account; empty for aws, + // which has no single assumed identity. + AssumedIdentity string `json:"assumed_identity,omitempty"` + // Accounts and SourceProfile describe an aws source's reach: the account ids + // the agent may select among and the operator's SSO base profile that backs + // them. Empty for gcp. + Accounts []string `json:"accounts,omitempty"` + SourceProfile string `json:"source_profile,omitempty"` + Valid bool `json:"valid"` + Hint string `json:"hint,omitempty"` } // connectionsResp builds the full response body for all /api/connections @@ -75,25 +82,37 @@ func (a *apiHandlers) cloudConnections(ctx context.Context) []cloudConnection { out := make([]cloudConnection, 0, len(a.prof.Cloud)) for _, src := range a.prof.Cloud { st := probe(ctx, src) - // A probe that fails before resolving an identity leaves Provider and - // AssumedIdentity blank. Fall back to the configured source values so a - // degraded source still shows WHICH identity was pinned alongside its - // failure hint; the alias is always the source's. + // 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 } - identity := st.AssumedIdentity - if identity == "" { - identity = src.AssumedIdentity + conn := cloudConnection{ + Alias: src.Alias, + Provider: provider, + Valid: st.Valid, + Hint: st.Hint, } - out = append(out, cloudConnection{ - Alias: src.Alias, - Provider: provider, - AssumedIdentity: identity, - Valid: st.Valid, - Hint: st.Hint, - }) + // The identity shape is provider-specific. gcp shows the one impersonated + // service account (the probe's resolved value, or the configured one when + // the probe degraded before resolving it). aws has no single identity: it + // shows the account set it spans and the operator's SSO base profile. + switch src.Provider { + case "aws": + conn.Accounts = make([]string, 0, len(src.Accounts)) + for _, acct := range src.Accounts { + conn.Accounts = append(conn.Accounts, acct.AccountID) + } + conn.SourceProfile = src.SourceProfile + default: + conn.AssumedIdentity = st.AssumedIdentity + if conn.AssumedIdentity == "" { + conn.AssumedIdentity = src.AssumedIdentity + } + } + out = append(out, conn) } return out } diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go index b62bc1c9..e7776626 100644 --- a/internal/server/handlers_connections_test.go +++ b/internal/server/handlers_connections_test.go @@ -293,7 +293,10 @@ func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { prof := &profile.Profile{ Cloud: []profile.CloudSource{ {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com"}, - {Alias: "prod-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, + {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{ @@ -303,7 +306,7 @@ func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { if src.Provider == "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"} + return cloud.IdentityStatus{Provider: "aws", Valid: false, Hint: "run: aws sso login"} }, } @@ -314,46 +317,55 @@ 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"` - Hint string `json:"hint"` + 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) + // gcp shows the one impersonated identity. 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.Empty(t, resp.Cloud[0].Accounts) assert.True(t, resp.Cloud[0].Valid) + // aws shows its account set + SSO base, never a single assumed 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.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_KeepsConfiguredIdentity asserts that when the -// probe fails before resolving an identity (empty Provider/AssumedIdentity), the -// cloud entry falls back to the profile source's configured provider, assumed -// identity, and alias, so the operator still sees WHICH identity was configured -// alongside valid:false and the failure hint. -func TestGetConnections_DegradedSource_KeepsConfiguredIdentity(t *testing.T) { +// 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-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro", SourceProfile: "sso", Accounts: []profile.CloudAccount{{AccountID: "1", RoleARN: "arn:aws:iam::1:role/ro"}}}, + {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, - // Provider construction failed before resolving an identity: the status - // carries only the failure signal, no provider or identity. + // 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: "run: aws sso login"} + return cloud.IdentityStatus{Valid: false, Hint: "re-authenticate"} }, } @@ -364,20 +376,28 @@ func TestGetConnections_DegradedSource_KeepsConfiguredIdentity(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"` - Hint string `json:"hint"` + 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, 1) - assert.Equal(t, "prod-aws", resp.Cloud[0].Alias) - assert.Equal(t, "aws", resp.Cloud[0].Provider, "degraded source must fall back to configured provider") - assert.Equal(t, "arn:aws:iam::1:role/ro", resp.Cloud[0].AssumedIdentity, "degraded source must fall back to configured identity") + 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, "run: aws sso login", resp.Cloud[0].Hint) + + 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) { diff --git a/pkg/mcp/cloud/providers/probe.go b/pkg/mcp/cloud/providers/probe.go index f2ed13b7..eac82407 100644 --- a/pkg/mcp/cloud/providers/probe.go +++ b/pkg/mcp/cloud/providers/probe.go @@ -45,12 +45,17 @@ type Source struct { } // ProbeSource constructs the source's provider and runs the read-only identity -// probe, threading the source's pinned identity and the subprocess credential -// env explicitly so concurrent probes for different sources never share state. -// It degrades, never blocks: a provider construction error (e.g. a missing CLI +// 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, @@ -59,12 +64,23 @@ func ProbeSource(ctx context.Context, src Source) cloud.IdentityStatus { if err != nil { return cloud.IdentityStatus{ Provider: src.Provider, - AssumedIdentity: src.AssumedIdentity, + AssumedIdentity: expected, Valid: false, Hint: err.Error(), } } - return probeProvider(ctx, p, src.AssumedIdentity, sourceEnvFor(p, src)) + 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 diff --git a/pkg/mcp/cloud/providers/probe_test.go b/pkg/mcp/cloud/providers/probe_test.go index 83d2624d..14cbddaa 100644 --- a/pkg/mcp/cloud/providers/probe_test.go +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -69,7 +69,7 @@ func TestProbeSourceDoesNotMutateProcessEnv(t *testing.T) { for _, src := range []Source{ {Provider: "gcp", AssumedIdentity: "ro-sa@proj.iam.gserviceaccount.com"}, - {Provider: "aws", AssumedIdentity: "arn:aws:iam::111122223333:role/triage-ro", Alias: "probe-aws", SourceProfile: "triage-ro", Accounts: []aws.Account{{ID: "111122223333", RoleARN: "arn:aws:iam::111122223333:role/triage-ro"}}}, + {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) } From 0af6dba366d0df38ed11bce2eab581aa15794301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 23:59:31 +0200 Subject: [PATCH 47/76] refactor(web): unify the cloud pill to one principal + reach shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both providers now render the same two-line pill — a principal over the reach it grants — with provider-specific content: gcp shows the impersonated service account over its allowlisted project count (an empty scope reads as "all projects"); aws shows the operator's SSO base profile over its account count. Each reach line carries its full members (projects / account ids) in the hover title. The connections DTO grows a gcp projects field alongside the aws accounts field already present. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/ConnectionsPanel.test.tsx | 31 +++++++---- frontend/components/ConnectionsPanel.tsx | 55 ++++++++++++------- frontend/lib/api.ts | 10 ++-- internal/server/handlers_connections.go | 33 ++++++----- internal/server/handlers_connections_test.go | 9 ++- 5 files changed, 83 insertions(+), 55 deletions(-) diff --git a/frontend/components/ConnectionsPanel.test.tsx b/frontend/components/ConnectionsPanel.test.tsx index 42f0aa62..393d93a0 100644 --- a/frontend/components/ConnectionsPanel.test.tsx +++ b/frontend/components/ConnectionsPanel.test.tsx @@ -30,7 +30,7 @@ describe("ConnectionsPanel cloud pills", () => { vi.restoreAllMocks(); }); - it("shows the assumed identity for gcp and the account set for aws", async () => { + it("renders the principal + reach shape per provider", async () => { vi.spyOn(api, "getConnections").mockResolvedValue({ ...baseStatus, cloud: [ @@ -38,6 +38,7 @@ describe("ConnectionsPanel cloud pills", () => { alias: "prod-gcp", provider: "gcp", assumed_identity: "triage-ro@prod.iam.gserviceaccount.com", + projects: ["prod-platform", "prod-data"], valid: true, }, { @@ -55,36 +56,42 @@ describe("ConnectionsPanel cloud pills", () => { expect(await screen.findByText("prod-gcp")).toBeInTheDocument(); expect(screen.getByText("prod-aws")).toBeInTheDocument(); - // gcp: the one impersonated service account. + // gcp: the impersonated service account over its allowlisted project count. expect( screen.getByText("triage-ro@prod.iam.gserviceaccount.com"), ).toBeInTheDocument(); - // aws: the account-set summary, never a single assumed identity. The full - // account ids live in the hover title. - const awsBody = screen.getByText("2 accounts · base: sso-admin"); - expect(awsBody).toBeInTheDocument(); - expect(awsBody).toHaveAttribute("title", "111111111111, 222222222222"); + 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("singularizes a one-account aws source", async () => { + 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: "prod-aws", + 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 · base: sso-admin"), - ).toBeInTheDocument(); + expect(await screen.findByText("1 account")).toBeInTheDocument(); + expect(screen.getByText("all projects")).toBeInTheDocument(); }); it("shows the reauth hint only for an invalid source", async () => { diff --git a/frontend/components/ConnectionsPanel.tsx b/frontend/components/ConnectionsPanel.tsx index 21dcf739..fa4c2fdb 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/ConnectionsPanel.tsx @@ -270,32 +270,45 @@ function CloudPill({ conn }: { conn: CloudConnection }) { ); } -// CloudPillBody renders the identity line, which differs by provider. GCP shows -// its one impersonated service account. AWS has no single assumed identity, so -// it shows the account set it spans and the operator's SSO base profile, with -// the full account ids in the hover title. +// 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 }) { - if (conn.provider === "aws") { - const accounts = conn.accounts ?? []; - const n = accounts.length; - const base = conn.source_profile ? ` · base: ${conn.source_profile}` : ""; - return ( -
- {n} account{n === 1 ? "" : "s"} - {base} -
- ); - } + 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 ( -
- {conn.assumed_identity} -
+ <> +
+ {principal} +
+
+ {reach} +
+ ); } +function countLabel(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? "" : "s"}`; +} + type ConnectionCardProps = { label: string; icon: React.ReactNode; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 8235dc7d..5b8fa089 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -99,15 +99,17 @@ export type ConnectionStatus = { // triagent-cloud- MCP and the request-time identity-probe result. valid // drives the checkmark; hint is the reauth advice shown when the probe failed. // -// The identity shape is provider-specific. gcp carries one assumed_identity (the -// impersonated service account). aws has no single assumed identity: it carries -// the accounts it spans and the operator's source_profile (the SSO base). +// 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; - accounts?: string[]; + projects?: string[]; source_profile?: string; + accounts?: string[]; valid: boolean; hint?: string; }; diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index 9f84a63a..92a58be2 100644 --- a/internal/server/handlers_connections.go +++ b/internal/server/handlers_connections.go @@ -39,16 +39,17 @@ type connectionsResponse struct { type cloudConnection struct { Alias string `json:"alias"` Provider string `json:"provider"` - // AssumedIdentity is the gcp impersonated service account; empty for aws, - // which has no single assumed identity. - AssumedIdentity string `json:"assumed_identity,omitempty"` - // Accounts and SourceProfile describe an aws source's reach: the account ids - // the agent may select among and the operator's SSO base profile that backs - // them. Empty for gcp. - Accounts []string `json:"accounts,omitempty"` - SourceProfile string `json:"source_profile,omitempty"` - Valid bool `json:"valid"` - Hint string `json:"hint,omitempty"` + // 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 scope.projects allowlist; + // empty means unconstrained). 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 @@ -95,22 +96,24 @@ func (a *apiHandlers) cloudConnections(ctx context.Context) []cloudConnection { Valid: st.Valid, Hint: st.Hint, } - // The identity shape is provider-specific. gcp shows the one impersonated - // service account (the probe's resolved value, or the configured one when - // the probe degraded before resolving it). aws has no single identity: it - // shows the account set it spans and the operator's SSO base profile. + // 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) } - conn.SourceProfile = src.SourceProfile default: conn.AssumedIdentity = st.AssumedIdentity if conn.AssumedIdentity == "" { conn.AssumedIdentity = src.AssumedIdentity } + conn.Projects = src.Scope.Projects } out = append(out, conn) } diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go index e7776626..a9aa20a8 100644 --- a/internal/server/handlers_connections_test.go +++ b/internal/server/handlers_connections_test.go @@ -292,7 +292,7 @@ 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"}, + {Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "ro@p.iam.gserviceaccount.com", Scope: cloud.ScopeAllowlist{Projects: []string{"prod-platform", "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"}, @@ -320,6 +320,7 @@ func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { 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"` @@ -329,17 +330,19 @@ func TestGetConnections_IncludesCloudArrayProbedAtRequestTime(t *testing.T) { require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) require.Len(t, resp.Cloud, 2) - // gcp shows the one impersonated identity. + // 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 shows its account set + SSO base, never a single assumed identity. + // 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) From 4f62fe22d33d249aad5e016a78f13c217da45e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:31:19 +0200 Subject: [PATCH 48/76] chore(docs): remove cloud-context design/plan artifacts from the PR Drop the feature-dev-workflow design artifacts this branch carried (the base and active-target design specs and the active-target plan). They are unreferenced by code; the shipped behaviour and its rationale live in the code, docs/content, and the PR/issue history. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-05-31-cloud-active-target-selection.md | 484 ------------------ .../2026-05-30-cloud-context-mcp-design.md | 117 ----- ...31-cloud-active-target-selection-design.md | 76 --- 3 files changed, 677 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-31-cloud-active-target-selection.md delete mode 100644 docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md delete mode 100644 docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md diff --git a/docs/superpowers/plans/2026-05-31-cloud-active-target-selection.md b/docs/superpowers/plans/2026-05-31-cloud-active-target-selection.md deleted file mode 100644 index bbb31c2b..00000000 --- a/docs/superpowers/plans/2026-05-31-cloud-active-target-selection.md +++ /dev/null @@ -1,484 +0,0 @@ -# Cloud active-target selection Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `set_active_target` tool that lets the operator agent choose which project (GCP) or account (AWS) subsequent `run_cli` commands run against, from a deployment-pinned set, applied as an MCP-controlled per-exec env var. - -**Architecture:** Extends the shipped cloud-context MCP (`pkg/mcp/cloud/`) — see `docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md`. The `cloud.Server` holds the active target as in-memory session state and merges a provider-supplied env var into each `run_cli` child (`CLOUDSDK_CORE_PROJECT` for GCP, `AWS_PROFILE` for AWS); the agent never supplies the target flags (they stay deny-floored). AWS multi-account uses a per-source `accounts` list; triagent generates the `~/.aws/config` profiles at startup, so it holds no credentials. - -**Tech Stack:** Go (`os/exec`, `os`, `text/template`/string building, `gopkg.in/ini.v1` or hand-written ini), the existing `pkg/mcp/cloud` package, `testify`. - -**Folds into:** the `feature/cloud-context-mcp` branch (PR #53). Two sub-PRs (A: provider-agnostic core; B: provider impls + AWS accounts config + launcher wiring), each self-merged into the feature branch. - ---- - -## Contracts - -The provider-agnostic core (PR A) defines two new `Provider` interface methods; the gcp/aws impls (PR B) realize them. - -| Name | Producer | Consumer | Shape | -| ---- | -------- | -------- | ----- | -| `provider-active-target` | PR A (interface + fake) | PR B (gcp, aws) | `ActiveTargetEnv(targetID string) []string` — the env var(s) to set for a target. GCP: `["CLOUDSDK_CORE_PROJECT="]`. AWS: `["AWS_PROFILE="]`. | -| `provider-configured-targets` | PR A | PR B | `ConfiguredTargets() []Target` — targets the provider itself knows from config. AWS returns its `accounts` list; GCP returns `nil` (its set comes from `scope.projects`/inventory). | - -`Target` is `struct { ID, Name string }` (same shape as `Scope`; reused conceptually). - -## Conventions - -Inherit the cloud package's conventions (provider impls live in `pkg/mcp/cloud/providers//`; CLI-only; testify table tests over fixtures; the deny floor and `Allows` are unchanged). New rules: - -- **Active target is per-exec env, never `os.Setenv`.** The `Server` stores the active target ID in memory and builds the target env into each `execCLI` call (extending `subprocessEnv`). No process-global mutation, matching the probe fix. -- **The agent never names a profile or arbitrary target.** `set_active_target` validates the ID against the selectable set and rejects anything else; `--project`/`--account`/`--profile` stay deny-floored. -- **triagent generates AWS profiles, holds no credential.** Generated profiles are written to a managed, clearly-delimited block in `$HOME/.aws/config`; each is `role_arn` + the source's `source_profile` (the operator's SSO base). Generation is idempotent. - ---- - -## PR A — provider-agnostic active-target core (against the fake) - -### Task A1: `Target` type and the two `Provider` methods - -**Files:** -- Modify: `pkg/mcp/cloud/provider.go` -- Modify: `pkg/mcp/cloud/fake_test.go` -- Test: `pkg/mcp/cloud/server_test.go` - -- [ ] **Step 1: Write the failing test** — `fakeProvider` satisfies the extended interface. - -```go -// server_test.go (add) -func TestFakeProviderSatisfiesActiveTargetContract(t *testing.T) { - var p Provider = &fakeProvider{} - require.NotNil(t, p) - // compile-time: the interface now includes ActiveTargetEnv + ConfiguredTargets -} -``` - -- [ ] **Step 2: Run to verify it fails** — `go test ./pkg/mcp/cloud/ -run TestFakeProviderSatisfiesActiveTargetContract` → FAIL (fakeProvider missing methods). - -- [ ] **Step 3: Add `Target` + the interface methods** in `provider.go`: - -```go -// Target is one selectable project (gcp) or account (aws) the agent may make active. -type Target struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// Provider (add to the interface): - // 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 -``` - -- [ ] **Step 4: Implement on `fakeProvider`** in `fake_test.go`: - -```go -func (f *fakeProvider) ConfiguredTargets() []Target { return f.targets } -func (f *fakeProvider) ActiveTargetEnv(id string) []string { return []string{"FAKE_TARGET=" + id} } -``` -Add `targets []Target` to the `fakeProvider` struct. - -- [ ] **Step 5: Run** `go test ./pkg/mcp/cloud/ -run TestFakeProviderSatisfiesActiveTargetContract -v` → PASS. - -- [ ] **Step 6: Commit** `feat(cloud): Target type and active-target provider methods (#47-followup)`. - -### Task A2: Server active-target state + selectable set + apply - -**Files:** -- Modify: `pkg/mcp/cloud/server.go` -- Test: `pkg/mcp/cloud/server_test.go` - -- [ ] **Step 1: Write failing tests** for the selectable set and env application: - -```go -func TestSelectableTargetsPrefersConfigured(t *testing.T) { - p := &fakeProvider{targets: []Target{{ID: "acct-1", Name: "one"}}} - s := newTestServer(t, p) // helper already present - got := s.selectableTargets(context.Background()) - assert.Equal(t, []Target{{ID: "acct-1", Name: "one"}}, got) -} - -func TestSetActiveTargetRejectsOutOfSet(t *testing.T) { - 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) -} - -func TestSubprocessEnvIncludesActiveTarget(t *testing.T) { - 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") -} -``` - -- [ ] **Step 2: Run** → FAIL (`selectableTargets`/`setActive`/`activeTarget` undefined). - -- [ ] **Step 3: Implement** on `Server` in `server.go`: - -```go -// add fields: activeTarget string - -// selectableTargets returns the set the agent may choose from: the provider's -// configured targets (aws accounts) when present, else the scope projects, else -// (unconstrained) the live inventory scopes. -func (s *Server) selectableTargets(ctx context.Context) []Target { - if t := s.provider.ConfiguredTargets(); len(t) > 0 { - return t - } - if len(s.scope.Projects) > 0 { - out := make([]Target, 0, len(s.scope.Projects)) - for _, p := range s.scope.Projects { - out = append(out, Target{ID: p, Name: p}) - } - return out - } - inv, err := s.provider.Inventory(ctx, s.run) - if err != nil { - return nil - } - out := make([]Target, 0, len(inv.Scopes)) - for _, sc := range inv.Scopes { - out = append(out, Target{ID: sc.ID, Name: sc.Name}) - } - return out -} - -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) -} -``` -Extend `subprocessEnv()` to append `s.provider.ActiveTargetEnv(s.activeTarget)` when `s.activeTarget != ""`: - -```go -func (s *Server) subprocessEnv() []string { - env := minimalEnv(s.provider.EnvPassthrough()) - if s.activeTarget != "" { - env = append(env, s.provider.ActiveTargetEnv(s.activeTarget)...) - } - return env -} -``` - -- [ ] **Step 4: Run** `go test ./pkg/mcp/cloud/ -run 'TestSelectable|TestSetActive|TestSubprocessEnvIncludes' -race -v` → PASS. - -- [ ] **Step 5: Default-active + require-selection.** Add to `New`: if exactly one configured target, set `activeTarget` to it. Add a `requireActiveTarget()` guard `run` uses (Task A4). Test: - -```go -func TestSingleTargetIsDefaultActive(t *testing.T) { - s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "only"}}}) - assert.Equal(t, "only", s.activeTarget) -} -func TestMultiTargetHasNoDefault(t *testing.T) { - s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "a"}, {ID: "b"}}}) - assert.Equal(t, "", s.activeTarget) -} -``` -Implement the single-target default in `New` (after constructing `s`, call `if t := s.provider.ConfiguredTargets(); len(t)==1 { s.activeTarget = t[0].ID }`; for gcp single scope project, mirror via `selectableTargets` length check). Run → PASS. - -- [ ] **Step 6: Commit** `feat(cloud): server active-target state, selectable set, and env application`. - -### Task A3: `set_active_target` tool - -**Files:** -- Create: `pkg/mcp/cloud/tools_target.go` -- Modify: `pkg/mcp/cloud/specs.go`, `pkg/mcp/cloud/server.go` (registerOn) -- Test: `pkg/mcp/cloud/tools_test.go`, `pkg/mcp/cloud/tools_wire_test.go` - -- [ ] **Step 1: Write failing tests** (driven by `fakeProvider`): `set_active_target` with a valid ID sets the active target and returns the new `session_status`; an invalid ID returns a tool error; the wire test includes `set_active_target`. - -```go -func TestSetActiveTargetTool(t *testing.T) { - s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", Valid: true}}) - _, out, err := s.setActiveTarget(context.Background(), nil, SetActiveTargetInput{Target: "acct-1"}) - require.NoError(t, err) - assert.True(t, out.Valid) - assert.Equal(t, "acct-1", s.activeTarget) - - res, _, _ := s.setActiveTarget(context.Background(), nil, SetActiveTargetInput{Target: "nope"}) - assert.True(t, res.IsError) -} -``` - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** `tools_target.go`: - -```go -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." - -type SetActiveTargetInput struct { - Target string `json:"target" jsonschema:"The project id (GCP) or account id (AWS) to activate, from list_inventory."` -} - -type SetActiveTargetOutput = IdentityStatus - -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.expectedIdentity, s.subprocessEnv()) - return nil, st, nil -} -``` -Register it in `registerOn` and add it to `ToolSpecs()` (between `session_status` and `run_cli`). - -- [ ] **Step 4: Run** `go test ./pkg/mcp/cloud/ -race -v` → PASS (incl. the wire test). - -- [ ] **Step 5: Commit** `feat(cloud): set_active_target tool and spec`. - -### Task A4: `run_cli` requires an active target when several exist - -**Files:** -- Modify: `pkg/mcp/cloud/server.go` (`run`), `pkg/mcp/cloud/tools_cli.go` -- Test: `pkg/mcp/cloud/tools_test.go` - -- [ ] **Step 1: Write failing test** — with multiple targets and none active, `run_cli` returns an actionable error; after `set_active_target`, it runs. - -```go -func TestRunCLIRequiresActiveTargetWhenMultiple(t *testing.T) { - s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "a"}, {ID: "b"}}, binary: "/bin/echo", - allow: &CommandAllowlist{Commands: []Command{{Path: "echo"}}}}) - res, _, _ := s.runCLI(context.Background(), nil, RunCLIInput{Argv: []string{"echo", "x"}}) - assert.True(t, res.IsError) - assert.Contains(t, errText(res), "set_active_target") -} -``` -(`errText` reads the error content; add if absent.) - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** — in `Server.run`, before `validateArgv`: `if s.activeTarget == "" && len(s.selectableTargets(ctx)) > 1 { return CLIResult{}, errNoActiveTarget }` where `var errNoActiveTarget = errors.New("no active target; call set_active_target to choose one")`. `runCLI` surfaces it as a tool error. - -- [ ] **Step 4: Run** `go test ./pkg/mcp/cloud/ -race -v` → PASS. - -- [ ] **Step 5: Commit** `feat(cloud): run_cli requires an active target when several are configured`. - -### Task A5: `session_status` reports the active target - -**Files:** -- Modify: `pkg/mcp/cloud/provider.go` (`IdentityStatus` add `ActiveTarget string json:"active_target,omitempty"`), `pkg/mcp/cloud/tools_status.go` -- Test: `pkg/mcp/cloud/tools_test.go` - -- [ ] **Step 1: Write failing test** — `session_status` includes the active target. - -```go -func TestSessionStatusReportsActiveTarget(t *testing.T) { - s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", Valid: true}}) - require.NoError(t, s.setActive("acct-1")) - _, out, _ := s.sessionStatus(context.Background(), nil, SessionStatusInput{}) - assert.Equal(t, "acct-1", out.ActiveTarget) -} -``` - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** — add `ActiveTarget` to `IdentityStatus`; in `sessionStatus`, set `st.ActiveTarget = s.activeTarget` on the returned status. (Probe leaves it empty; the server fills it.) - -- [ ] **Step 4: Run** → PASS. - -- [ ] **Step 5: Commit** `feat(cloud): session_status reports the active target`. - ---- - -## PR B — provider impls, AWS accounts config, launcher wiring - -### Task B1: GCP provider implements the active-target methods - -**Files:** -- Modify: `pkg/mcp/cloud/providers/gcp/provider.go` -- Test: `pkg/mcp/cloud/providers/gcp/provider_test.go` - -- [ ] **Step 1: Failing test:** - -```go -func TestGCPActiveTargetEnv(t *testing.T) { - p, _ := newWithBinary("/usr/bin/gcloud") - assert.Equal(t, []string{"CLOUDSDK_CORE_PROJECT=proj-1"}, p.ActiveTargetEnv("proj-1")) - assert.Nil(t, p.ConfiguredTargets()) -} -``` - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** on the gcp `Provider`: - -```go -func (p *Provider) ConfiguredTargets() []cloud.Target { return nil } -func (p *Provider) ActiveTargetEnv(id string) []string { return []string{"CLOUDSDK_CORE_PROJECT=" + id} } -``` - -- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(cloud/gcp): active-target via CLOUDSDK_CORE_PROJECT (#43-followup)`. - -### Task B2: Profile config + AWS `accounts` on the profile model - -**Files:** -- Modify: `internal/profile/profile.go` (`CloudSource`), `internal/profile/validate.go` -- Test: `internal/profile/profile_test.go` - -- [ ] **Step 1: Failing test** — a `cloud:` aws source parses an `accounts` list and a `source_profile`; validation requires `source_profile` and at least one account when `accounts` is used, and unique account ids. - -```go -func TestCloudSourceAWSAccounts(t *testing.T) { - p, err := Parse([]byte(` -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"} -`)) - require.NoError(t, err) - require.NoError(t, p.Validate()) - assert.Len(t, p.Cloud[0].Accounts, 2) -} -``` -Add a negative case: duplicate account_id → `Validate` error; aws `accounts` without `source_profile` → error. - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** — extend `CloudSource`: - -```go -type CloudAccount struct { - AccountID string `yaml:"account_id"` - RoleARN string `yaml:"role_arn"` -} -// in CloudSource: - SourceProfile string `yaml:"source_profile,omitempty"` // aws SSO base profile for generated assume-role profiles - Accounts []CloudAccount `yaml:"accounts,omitempty"` -``` -Extend `Validate`: for an aws source with `Accounts`, require `SourceProfile`, each `account_id`/`role_arn` non-empty, and account ids unique. The single-`assumed_identity` form stays valid (one-account case). - -- [ ] **Step 4: Run** `go test ./internal/profile/ -race -v` → PASS. **Step 5: Commit** `feat(profile): aws cloud accounts list + source_profile (#47-followup)`. - -### Task B3: AWS provider — configured targets, generated profiles, active-target env - -**Files:** -- Modify: `pkg/mcp/cloud/providers/aws/provider.go` -- Create: `pkg/mcp/cloud/providers/aws/profiles.go` (profile generation) -- Test: `pkg/mcp/cloud/providers/aws/provider_test.go`, `profiles_test.go` - -- [ ] **Step 1: Failing test for the profile generator** — given accounts + a source_profile + an alias, it produces a managed `~/.aws/config` block, idempotently. - -```go -func TestGenerateProfilesBlock(t *testing.T) { - dir := t.TempDir(); cfg := filepath.Join(dir, "config") - accs := []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/triage-readonly"}} - require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) - b, _ := os.ReadFile(cfg) - assert.Contains(t, string(b), "[profile triagent-cloud-prod-aws-111111111111]") - assert.Contains(t, string(b), "role_arn = arn:aws:iam::111111111111:role/triage-readonly") - assert.Contains(t, string(b), "source_profile = sso-admin") - // idempotent: second write does not duplicate - require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) - b2, _ := os.ReadFile(cfg) - assert.Equal(t, 1, strings.Count(string(b2), "[profile triagent-cloud-prod-aws-111111111111]")) -} -``` - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** `profiles.go`: `writeManagedProfiles(configPath, alias, sourceProfile string, accs []Account)` rewrites a delimited block (`# BEGIN triagent-cloud-` … `# END triagent-cloud-`) atomically (tmp-file-then-rename, the repo's `atomicWrite` idiom), replacing any prior block for that alias so it is idempotent. Profile name: `triagent-cloud--`. Define `profileName(alias, accountID)` for reuse. - -- [ ] **Step 4: Implement the provider methods.** `New` gains the accounts + alias + source_profile (passed from serve.go, Task B5); store `accounts []Account` and `alias`. On `New`, call `writeManagedProfiles($HOME/.aws/config or AWS_CONFIG_FILE, alias, sourceProfile, accounts)`. - -```go -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}) } - return out -} -func (p *Provider) ActiveTargetEnv(id string) []string { - return []string{"AWS_PROFILE=" + profileName(p.alias, id)} -} -``` -Test `ConfiguredTargets`/`ActiveTargetEnv` against a provider built with two accounts. - -- [ ] **Step 5: Run** `go test ./pkg/mcp/cloud/providers/aws/ -race -v` → PASS. **Step 6: Commit** `feat(cloud/aws): configured accounts, generated profiles, active-target env (#46-followup)`. - -### Task B4: AWS inventory reflects the configured accounts - -**Files:** -- Modify: `pkg/mcp/cloud/providers/aws/inventory.go` -- Test: `pkg/mcp/cloud/providers/aws/inventory_test.go` - -- [ ] **Step 1: Failing test** — when the provider has a configured `accounts` list, `Inventory` returns exactly those accounts (the reachable/selectable set), without calling `organizations list-accounts`. - -```go -func TestInventoryUsesConfiguredAccounts(t *testing.T) { - p := providerWithAccounts(t, []Account{{ID: "111111111111"}, {ID: "222222222222"}}) - inv, err := p.Inventory(context.Background(), failRun(t)) // run must NOT be called - require.NoError(t, err) - assert.Len(t, inv.Scopes, 2) -} -``` -(`failRun` fails the test if invoked.) - -- [ ] **Step 2: Run** → FAIL (current Inventory calls `organizations list-accounts`). - -- [ ] **Step 3: Implement** — when `len(p.accounts) > 0`, `Inventory` returns those as `Scopes` directly. When empty (single-account legacy), keep the existing `organizations list-accounts` + caller-account fallback. - -- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `fix(cloud/aws): inventory reflects the configured accounts, not the whole org`. - -### Task B5: serve.go + mcpconfig wiring - -**Files:** -- Modify: `cmd/triagent-mcp/serve.go` (`runCloud`, env consts), `internal/preflight/mcpconfig.go`, `pkg/mcp/cloud/env.go` -- Test: `cmd/triagent-mcp/serve_cloud_test.go`, `internal/preflight/mcpconfig_test.go` - -- [ ] **Step 1: Failing test (mcpconfig)** — an aws `CloudSource` with `accounts` + `source_profile` emits the accounts + source_profile to the cloud subprocess (JSON env `TRIAGENT_CLOUD_AWS_ACCOUNTS`, `cloud.EnvAWSSourceProfile`), and a gcp source is unaffected. - -```go -func TestCloudSourceAWSAccountsEnv(t *testing.T) { - env, err := cloudSourceEnv(profile.CloudSource{Alias: "prod-aws", Provider: "aws", SourceProfile: "sso-admin", - Accounts: []profile.CloudAccount{{AccountID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}}}) - require.NoError(t, err) - assert.Contains(t, env[cloud.EnvAWSSourceProfile], "sso-admin") - assert.NotEmpty(t, env[cloud.EnvAWSAccounts]) // JSON array -} -``` - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** — add `cloud.EnvAWSAccounts = "TRIAGENT_CLOUD_AWS_ACCOUNTS"` (JSON) and `cloud.EnvAWSSourceProfile = "TRIAGENT_CLOUD_AWS_SOURCE_PROFILE"` to `env.go`. `cloudSourceEnv` JSON-encodes the accounts and sets the source_profile for aws sources. `runCloud` decodes them and passes them to `aws.New(...)`; for the single-`assumed_identity` legacy form, build a one-element accounts list. Pass the source alias through (already available as the MCP alias). - -- [ ] **Step 4: Run** `go test ./cmd/triagent-mcp/ ./internal/preflight/ -race -v` → PASS. - -- [ ] **Step 5: Full verification** — `make test-go`, `make lint`, and `make build` all green. Commit `feat(cloud): wire aws accounts + source_profile through serve and mcpconfig (#47-followup)`. - -### Task B6: docs - -**Files:** -- Modify: `docs/content/cloud-providers.md` -- Test: a fresh-reader pass per `feature-dev-workflow:writing-docs` (RED/GREEN), then `make docs`. - -- [ ] **Step 1:** Document `set_active_target`, the AWS `accounts` + `source_profile` config (with a multi-account example), the "one identity many projects (GCP) vs one role per account (AWS)" model, and that `run_cli` requires an active target when several are configured. Run the writing-docs fresh-reader loop; `make docs` builds clean. Commit `docs(cloud): document multi-account/project active-target selection`. - ---- - -## Self-review - -- **Spec coverage:** `set_active_target` (A3); selectable set incl. scope/inventory fallback (A2); per-exec env apply, never os.Setenv (A2 `subprocessEnv`); default-active + require-selection (A2/A4); GCP `CLOUDSDK_CORE_PROJECT` (B1); AWS accounts config (B2), generated profiles + `AWS_PROFILE` (B3); inventory honesty (B4); launcher wiring (B5); session_status active target (A5); docs (B6). The runtime-AssumeRole broker stays rejected (no task builds it). Region switching untouched (no task). -- **Placeholder scan:** the AWS profile-generation ini format, the env-var names, the tool schema, and all test bodies are spelled out; no TBD/TODO. -- **Type consistency:** `Target{ID,Name}`, `ActiveTargetEnv(string) []string`, `ConfiguredTargets() []Target`, `CloudAccount{AccountID,RoleARN}`, `SetActiveTargetInput{Target}`, `profileName(alias, accountID)`, `EnvAWSAccounts`/`EnvAWSSourceProfile` are used consistently across PR A and PR B. diff --git a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md b/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md deleted file mode 100644 index 39aced7d..00000000 --- a/docs/superpowers/specs/2026-05-30-cloud-context-mcp-design.md +++ /dev/null @@ -1,117 +0,0 @@ -# Read-only cloud-context MCP (GCP and AWS) - -## Problem - -When triaging a Kubernetes incident, the operator agent can inspect the cluster (`triagent-k8s`) and reach it through Teleport (`triagent-teleport`), but it is blind to the cloud layer the cluster sits on. A large class of incidents is only explicable from cloud context: a Pod cannot reach a dependency because of a firewall rule or security group or a missing route; a workload is denied because an identity lost a binding or a bucket policy changed; the managed cluster (GKE / EKS) 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. - -Today answering any of these means a human drops into a cloud console, breaking the investigation loop. The two clouds the platform runs on, GCP and AWS, ask the same handful of questions through entirely different APIs, so the naive fix — one bespoke MCP per cloud, with a hand-written typed tool per resource — is a treadmill: the space of things a responder eventually wants to read is effectively the whole cloud API. - -This spec defines a single read-only cloud-context MCP that gives the agent that context without ever being able to mutate cloud state or escalate its own privilege. - -## Goals - -- Let the operator agent answer cloud-context questions (reachability, permissions, cluster setup, logs, audit trail, inventory) for GCP and AWS from inside an investigation, without a human leaving the loop. -- Make adding coverage a config edit, not new Go; make adding a cloud a new provider behind one interface, not a parallel MCP. -- Guarantee read-only by construction and by harness, with a safety boundary the agent provably cannot bypass. -- Pin the cloud identity to a deployment-chosen, read-only principal that the agent can neither select nor escalate. -- Surface cloud auth readiness before a session starts, so the operator fixes a stale credential proactively rather than discovering a degraded session. - -## Non-goals - -- Any write, create, update, or delete operation against either cloud. Read-only is absolute. -- Clouds beyond GCP and AWS. The provider interface should not foreclose a third, but none ships here. -- Reading secrets, downloading bucket objects, shelling into instances, or impersonating identities of the agent's choosing. These sit on a hardcoded deny floor regardless of config. -- OAuth / SSO browser login flows inside triagent. Base authentication is the operator's own (or the workload's); triagent never runs an interactive login. This is a candidate future enhancement, not v1. -- Billing, cost, or quota reporting. - -## Design overview - -One package, `pkg/mcp/cloud/`, exposing `New(Options)` + `Run(ctx)` + a sibling `specs.go::ToolSpecs()`, registered with one `case "cloud"` in `cmd/triagent-mcp/serve.go` (ADR-0001) and selected at launch by `--provider=gcp|aws`. This mirrors the git MCP, which is one package bound per-repo via `--repo` and aliased `triagent-git-` at the `mcpconfig.go` wiring layer (`internal/preflight/mcpconfig.go`, ADR-0003); here the bound target is a cloud provider, aliased `triagent-cloud-`. Deployment config (provider, pinned identity, scope allowlist, command-allowlist override path) loads from the runtime profile (ADR-0008). - -The tool surface is provider-agnostic and lives once in `specs.go`. It is deliberately thin: two typed tools where shaped output clearly pays its context cost, plus a gated CLI escape hatch for the long tail. - -- `list_inventory` — projects / accounts and the accessible resources within an allowlisted scope, so the agent can orient. -- `session_status` — the read-only whoami: which pinned identity is active and whether it is valid. -- `run_cli` — a gated, read-only `gcloud` / `aws` invocation for everything else, with argument tokens supplied as an array. -- `list_allowed_commands` — the discovery tool that reads the same gating config `run_cli` enforces, so what is advertised is exactly what is permitted. - -Each typed tool calls through a `Provider` interface; selecting `--provider` chooses the concrete `gcp` or `aws` implementation, plugged in behind the interface exactly like the git MCP's `ghRunner` real-vs-stub seam. Providers return curated projections rather than raw API JSON, following the `pkg/mcp/k8s` `redact.go` discipline. - -```mermaid -flowchart TD - operator[operator agent] --> typed["typed tools
list_inventory · session_status"] - operator --> disc["list_allowed_commands"] - operator --> cli["run_cli
(argv tokens only)"] - typed --> iface{{Provider interface}} - cli --> harness["safe harness
no shell · fixed binary · allowlist
+ deny floor (subcommands & flags)
+ scope check + truncate"] - cfg[("command allowlist
embedded default,
profile-overridable")] --> harness - cfg --> disc - harness --> iface - iface --> gcp["gcp provider
gcloud + defaults"] - iface --> aws["aws provider
aws + defaults"] - id[("pinned read-only identity
impersonated via harness env")] -.outer floor.-> gcp - id -.outer floor.-> aws -``` - -## Security model - -The security model is the heart of this feature. It has two independent layers: the agent cannot run a forbidden command, and the agent cannot act as a forbidden identity. - -### The command harness cannot be bypassed - -`run_cli` never touches a shell. The guarantee is structural, not a matter of sanitizing strings. - -- **Argv-only input.** The tool input is a typed array of argument tokens, never a single command string. The harness never tokenizes a string itself, so there is no in-house splitter to fool. -- **Direct `execve`, no shell.** The harness execs the provider's fixed binary with the argv array (`exec.CommandContext`). No `sh -c` exists anywhere in the package. Shell metacharacters (`|`, `;`, `&&`, `$(…)`, backticks, `>`, newlines) have meaning only to a shell; handed to `gcloud`/`aws` as literal argv tokens they are inert and rejected by the binary. A unit test asserts no `sh -c` / `bash -c` construction exists and that an argv full of metacharacters never spawns a second process. -- **Positive allowlist on the normalized subcommand path** (for example `compute firewall-rules list`, `projects list`), loaded from an embedded default JSON overridable via a profile-pointed path. This is the `LoadAllowlist` pattern from `pkg/mcp/k8s/allowlist.go`: embedded default, optional override, applied identically. -- **A hardcoded deny floor the config can never re-enable**, mirroring how `LoadAllowlist` always filters `Secret` regardless of the kinds config. The floor covers dangerous subcommands (`secrets ... access`, `ssh`/`scp`, `cp`/`sync`, `auth`, `config set`) and dangerous flags (`--impersonate-service-account`, `--account`, `--profile`, `--endpoint-url`, `--cli-input-*`, `--configuration`), plus argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). -- **Scope validation.** Any `--project` and region/zone (`--region` / `--zone`) in the argv must be in the profile's scope allowlist, so the agent cannot pivot to an un-allowlisted target. Account selection is not scope-validated on argv: `--account` and `--profile` are deny-floored, and account reach is constrained by the pinned identity (`ScopeAllowlist.Accounts` is informational — the AWS account an agent can touch is bounded by the assume-role profile's `role_arn`, not by an argv flag). -- **Output truncation** keeps a raw response from blowing the context budget. -- **Pinned binary and minimal env.** The binary is resolved to an absolute path once at startup; the subprocess runs with an explicit minimal `cmd.Env` (so a poisoned `PATH` cannot substitute a different binary) and closed stdin (no interactive prompt or fed input). - -### The agent cannot select or escalate identity - -The cloud identity is a deployment-chosen, read-only principal pinned in the profile. The agent can read which identity is active (`session_status`, `list_allowed_commands`) but has no tool to choose, change, or authenticate one. - -The identity is a stable contract; how the harness acquires credentials for it is a swappable realization, set by the deployment and injected through `cmd.Env` (which the agent never controls — it supplies argv only): - -- **Operator-ambient base auth plus harness-pinned impersonation (v1 primary).** The operator is authenticated as themselves through their own normal tooling (`gcloud auth login`, `aws sso login`). The harness pins impersonation of the configured read-only identity via environment: `CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=` for GCP; `AWS_PROFILE=` (a profile whose `role_arn` is the read-only role with the operator's base as `source_profile`) for AWS. triagent stores no credential. Because the pin is in env, not argv, `--impersonate-service-account` and `--profile` stay on the agent deny floor without contradiction. Re-authentication is the operator's own corporate flow, outside triagent. -- **Workload Identity / IRSA (server / headless).** The workload is the pinned identity; base credentials come from the metadata server. This falls out of the same env-injection code path with the base credential sourced from the environment instead of the operator. triagent stores no credential. -- **Static read-only key connection (deferred fallback).** A service-account key (GCP) or static access keys (AWS) pasted into the connections panel, for environments where assume-role is not granted. This is the only realization where triagent holds a secret; it is out of v1 scope and slots in later behind the same connection surface and env injection. - -The deployment's read-only IAM grant on the pinned identity is the outermost floor: even a misconfigured-too-broad command allowlist cannot read secrets or exfiltrate, because the identity itself lacks the permission. - -## Auth readiness, preflight, and visible degrade - -A single whoami probe validates the identity chain: base credentials valid, impersonation / assume-role succeeds, and the resolved identity matches the pinned one. That one probe serves three surfaces so they can never disagree: - -- **The connections panel (pre-session visibility).** Cloud appears in the same sidebar panel as Slack and incident.io, but read-only: it is configured in the profile, not entered there. `GET /api/connections` grows a `cloud` array of `{provider, assumed_identity, valid, hint}`. The assumed identity always shows; the validity checkmark and the `ReauthAdvisor` hint (`run: gcloud auth login`) come from the probe, run on panel load so the operator fixes a stale credential before starting. -- **Session preflight (the gate).** `preflight.Run()` re-runs the same probe through the existing `auth.Provider` seam. This extends the current authentication preflight rather than adding new machinery. -- **Visible degrade, not block.** Unlike the current k8s auth preflight, a failed cloud probe does not fail the session. The session starts with the cloud source disabled and visibly marked unavailable; Kubernetes triage proceeds without the cloud axis. A stale cloud credential must never block all investigation. This introduces a soft-degrade path the preflight does not have today. - -## Risks and mitigations - -- **The agent bypasses the command safety net** (shell metacharacters, flag escapes, identity/endpoint redirection, scope pivot). Mitigated by structural defenses, not string filtering: no shell ever (argv + direct `execve`); a deny floor covering subcommands, flags, and argument prefixes; scope validation. The read-only IAM grant is an independent backstop. -- **Advertised commands drift from enforced commands.** `list_allowed_commands` and `run_cli` read one config; the allowlist is the single source of truth. -- **The agent widens its own allowlist or picks its identity.** The config and the pinned identity load server-side from the profile; the agent has tools to read them, none to mutate them. Impersonation is pinned in harness-controlled env, never agent argv. -- **Raw CLI output blows the context budget.** Output truncation on the escape hatch, plus typed tools for the orientation path. -- **Operator-ambient impersonation needs an IAM grant** (assume-role / `serviceAccountTokenCreator` on the pinned role). This is a one-time admin setup and the price of not storing a secret; documented as a deployment prerequisite. Workload Identity is the no-grant alternative for server deployments. -- **Soft-degrade is new preflight behavior.** The degrade path is cloud-source-scoped and explicit; the existing k8s block-on-failure behavior is unchanged. - -## Alternatives considered - -- **One bespoke MCP per cloud (`triagent-gcp`, `triagent-aws`).** Rejected: copy-pasted tool plumbing across two packages, against the "prefer a shared helper to a second consumer" and "don't introduce a new top-level MCP for a bound target" conventions. The provider abstraction collapses both into one package bound by `--provider`, the git-MCP pattern. -- **A fat typed-tool surface, one per resource.** Rejected as a treadmill: the readable surface is effectively the whole cloud API. The thin-typed-plus-gated-CLI split covers the long tail through config instead of code, and any axis can be promoted to a typed tool later when its raw output proves too noisy. -- **CLI-only, no typed tools.** Rejected: orientation (`list_inventory`) and the auth whoami (`session_status`) earn shaped output, and raw `--format=json` dumps are exactly the context cost `redact.go` exists to avoid. -- **Read-only enforced solely by IAM, free-form CLI on top.** Rejected as the whole story: read-only IAM still reads secrets and exfiltrates bucket objects, so "read-only" is necessary but not sufficient. The harness deny floor is what excludes those; IAM is the backstop, not the fence. -- **triagent holds a stored cloud credential as the primary model** (static key connection). Rejected as v1 primary: it puts a long-lived secret in triagent's custody and forces in-app re-auth. Operator-ambient impersonation stores nothing, gives a better audit trail (human plus role), and pushes re-auth to the operator's existing tooling. The stored-key connection survives as a deferred fallback for environments without assume-role. -- **OAuth / SSO login inside triagent.** Deferred: a different tier of work (callback handling, refresh-token storage and rotation, per-provider divergence, expiry visibility) for marginal gain over piggybacking on the operator's own session. Slots in later as one more env source behind the same connection. -- **Block the session on cloud auth failure** (mirroring k8s preflight). Rejected: cloud is secondary context; a stale cloud credential must not make a Kubernetes incident un-investigable. Visible degrade keeps triage moving. - -## Vocabulary - -- The server is the **cloud-context MCP**; instances are aliased **`triagent-cloud-`**. -- The swappable backend is a **provider** (`gcp`, `aws`) behind the **`Provider` interface**. -- The gated escape hatch is **`run_cli`**; its catalog is **`list_allowed_commands`**. -- The deployment-chosen identity is the **pinned identity**; the ways the harness acquires credentials for it are **realizations** (operator-ambient impersonation, Workload Identity, static-key connection). -- The investigative groupings (inventory, reachability, permissions, cluster, logs, audit) are **axes** — a navigational vocabulary for organizing coverage, never a code identifier. diff --git a/docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md b/docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md deleted file mode 100644 index f50278c5..00000000 --- a/docs/superpowers/specs/2026-05-31-cloud-active-target-selection-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# Cloud active-target selection (extends the cloud-context MCP) - -This design extends the read-only cloud-context MCP defined in [2026-05-30-cloud-context-mcp-design.md](2026-05-30-cloud-context-mcp-design.md). That base MCP (the `Provider` interface, the no-shell harness, the command allowlist and deny floor, the identity probe, and the GCP/AWS providers) ships unchanged. This document specifies only the addition: letting the agent operate across more than one project or account, by selecting an active target from a deployment-pinned set. - -## Problem - -The base MCP pins one target per cloud source and the agent cannot change it. That fits GCP, where a single impersonated service account can be granted read-only on many projects, so one identity already spans them. It does not fit AWS: an IAM role lives in exactly one account, so a single assumed role can only read one account's resources. A responder investigating an incident that crosses accounts (or that lives in a different account than the one the source happens to pin) has no way to follow the thread without a second source and a human switch. - -Two consequences in the base MCP make this concrete. The AWS `list_inventory` runs `organizations list-accounts` and can advertise the whole org, yet `run_cli` only works in the role's own account, so inventory over-promises reachability. And scope only constrains an explicit `--project`/`--region`; omitting the flag falls back to the CLI's ambient default, which scope does not police. - -## Goals - -- Let the agent operate across the deployment's set of projects (GCP) and accounts (AWS) within one cloud source, by selecting which target subsequent `run_cli` commands run against. -- Keep the selection bounded: the agent may choose only among targets the deployment configured, and can neither name an arbitrary target nor escalate within the set. -- Make the active target the effective default, so a command that omits the target flag runs against an in-scope target rather than an ambient one. This closes the scope-by-omission gap. -- Hold no credential. The mechanism stays env injection, the same as the base MCP's pinned identity. - -## Non-goals - -- Runtime credential brokering. triagent does not call `sts:AssumeRole` itself or hold temporary credentials (see Alternatives). -- Region switching. Region/zone stays a scope-validated explicit argv flag as in the base MCP; this feature is about project/account only. -- Clouds beyond GCP and AWS, and any write path. Unchanged from the base MCP. -- Choosing a target the deployment did not configure. The selectable set is the profile's, not the agent's. - -## Design - -### The `set_active_target` tool - -One tool is added to the provider-agnostic surface in `specs.go`: - -- `set_active_target` — input `target`: an ID from `list_inventory` (a project id for GCP, an account id for AWS). It sets the session's active target and returns the new target's `session_status` so the agent immediately sees whether it is valid. A `target` outside the pinned set is rejected before anything changes. - -`session_status` is extended to report the active target alongside the identity. `list_inventory` already surfaces the selectable set as its `Scopes`. - -### The selectable set - -- **GCP**: the profile's `scope.projects`. When that axis is unconstrained (empty), the set is the projects `list_inventory` surfaces (those the impersonated service account can see). -- **AWS**: a new per-source `accounts` list, each entry `{account_id, role_arn}`. The single-account deployment is a one-entry list, which reproduces today's behavior. - -### Applying the selection (the leak-safe rule) - -The active target is in-memory state on the cloud MCP `Server`, applied through one MCP-controlled environment variable built fresh into each `run_cli` child process, and **never** through a process-global `os.Setenv` (the same discipline the identity probe already follows, so there is no cross-request or cross-session bleed): - -- **GCP**: `CLOUDSDK_CORE_PROJECT=`. gcloud uses it as the default project for every command; commands that take no project ignore it. -- **AWS**: `AWS_PROFILE=`. - -The values are non-secret identifiers (a project id, a profile name), not credentials. `--project`, `--account`, `--profile`, and `--impersonate-service-account` stay on the deny floor, so the agent cannot override the MCP's pin; `set_active_target` is the only way to change the target. - -### GCP versus AWS, by mechanism - -- **GCP — one identity, many projects.** A single impersonated read-only service account spans the allowlisted projects. Switching changes only `CLOUDSDK_CORE_PROJECT`; the identity is unchanged. `session_status` reports the same service account throughout, with the active project alongside. -- **AWS — one account per role.** Each configured account is its own read-only role. triagent generates a `~/.aws/config` profile per `accounts` entry at startup, each layering the entry's `role_arn` over the operator's SSO `source_profile`; switching sets `AWS_PROFILE` to the active account's generated profile. The identity changes per account, so `session_status` re-probes the active role on switch. triagent still stores no credential: it sets a profile name and the AWS CLI performs the assume-role from the operator's base. - -### Default active target - -If the set has exactly one entry, it is the active target from session start (today's behavior). If it has several, `run_cli` returns an actionable error until the agent calls `set_active_target`, so a command can never run against an unintended default. - -## Security model - -This preserves the base MCP's two-layer model and tightens one part of it. The agent still cannot run a forbidden command (the harness, allowlist, and deny floor are unchanged). The identity layer changes from "the agent cannot select the target" to "the agent selects within a deployment-pinned set, never beyond it": the selectable set loads server-side from the profile, the agent has a tool to choose among its members but none to mutate it or add to it, and the target-selecting flags remain deny-floored so the choice can only be applied through the MCP's controlled env. The read-only IAM grant on each pinned identity remains the outermost floor. - -The scope-by-omission gap the base MCP documents is closed here: because the active target is an MCP-pinned in-scope value rather than the CLI's ambient default, a command that omits the flag still runs against an allowlisted target. - -## Builds on (unchanged) - -The harness, command allowlist, deny floor, identity probe, output truncation, provider interface, and the visible-degrade preflight from the base MCP are unchanged. This feature adds the `set_active_target` tool, the AWS `accounts` config plus its startup profile generation, the active-target session state, and the per-exec env application. The AWS `list_inventory` is adjusted so it reflects the reachable target set rather than over-advertising the whole organization. - -## Alternatives considered - -- **Runtime `AssumeRole` brokering for AWS.** The MCP would call `sts:AssumeRole` on switch and use the returned temporary credentials for `run_cli`. Rejected: it removes the per-account profile setup but reverses the base MCP's defining invariant (triagent would hold and refresh live credentials), enlarges the leak surface, and diverges from GCP. Generating per-account profiles and switching `AWS_PROFILE` gives the same ergonomics with no credential in triagent's custody. -- **Injecting the GCP `--project` flag.** The MCP would append `--project=` to each `run_cli`. Rejected for asymmetry and fragility: it needs per-command knowledge of which commands accept `--project`, where the env var (`CLOUDSDK_CORE_PROJECT`) applies uniformly and matches the AWS shape. - -## Vocabulary - -- The project (GCP) or account (AWS) the agent currently operates in is the **active target**, chosen from the deployment-pinned set via **`set_active_target`** and applied through the MCP's controlled env, never agent argv. -- The base MCP's terms (pinned identity, realizations, provider, axes, the gated `run_cli`) carry over unchanged. From 99613fcb456e46ecd466ab943342377872ecfa5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:54:15 +0200 Subject: [PATCH 49/76] feat(cloud): deployment tags on selectable targets, returned by list_inventory Each selectable target carries an arbitrary set of deployment-supplied tags (strings) that list_inventory returns, so the agent can judge which project/ account an investigation belongs to. Tags are free-form, unvalidated, and not security-bearing. Targets are now configured symmetrically per provider: aws accounts gain a tags field; gcp gains a top-level projects list of {id, tags} (replacing scope.projects, which is removed from ScopeAllowlist). list_inventory returns the configured set with tags for both providers; gcp live-lists projects (untagged) only when no projects are configured, mirroring aws. Tags thread from the profile through the launcher env (new TRIAGENT_CLOUD_GCP_PROJECTS; aws accounts JSON now carries tags) into Scope/Target and the list_inventory output schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 34 ++++++++++- cmd/triagent-mcp/serve_cloud_test.go | 29 ++++++++- docs/content/cloud-providers.md | 38 +++++++----- docs/content/profiles.md | 14 +++-- internal/preflight/mcpconfig.go | 7 +++ internal/preflight/mcpconfig_test.go | 15 +++-- internal/profile/profile.go | 31 +++++++--- internal/profile/profile_test.go | 61 +++++++++++++++++-- .../profile/profiles/default/profile.yaml | 7 ++- internal/profile/validate.go | 25 ++++++++ internal/server/handlers_connections.go | 5 +- internal/server/handlers_connections_test.go | 2 +- pkg/mcp/cloud/env.go | 14 +++-- pkg/mcp/cloud/provider.go | 18 ++++-- pkg/mcp/cloud/providers/aws/inventory.go | 2 +- pkg/mcp/cloud/providers/aws/inventory_test.go | 16 +++++ pkg/mcp/cloud/providers/aws/provider.go | 8 ++- pkg/mcp/cloud/providers/gcp/inventory.go | 19 ++++-- pkg/mcp/cloud/providers/gcp/inventory_test.go | 39 ++++++++++++ pkg/mcp/cloud/providers/gcp/provider.go | 49 ++++++++++++--- pkg/mcp/cloud/providers/registry.go | 14 +++-- pkg/mcp/cloud/server.go | 11 +--- pkg/mcp/cloud/server_test.go | 9 ++- pkg/mcp/cloud/validate.go | 17 +++--- pkg/mcp/cloud/validate_test.go | 4 +- 25 files changed, 375 insertions(+), 113 deletions(-) diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index a3930315..fcfd8f86 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -14,6 +14,7 @@ import ( "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" @@ -458,10 +459,15 @@ func runCloud(ctx context.Context, f serveFlags) error { 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, }) if err != nil { return err @@ -505,19 +511,41 @@ func parseAWSAccounts(raw string) ([]aws.Account, error) { return nil, nil } var wire []struct { - AccountID string `json:"account_id"` - RoleARN string `json:"role_arn"` + 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}) + 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 index bccde5ef..5dfa043a 100644 --- a/cmd/triagent-mcp/serve_cloud_test.go +++ b/cmd/triagent-mcp/serve_cloud_test.go @@ -39,17 +39,40 @@ func TestParseCloudScope_EmptyYieldsUnconstrained(t *testing.T) { t.Parallel() scope, err := parseCloudScope("") require.NoError(t, err) - assert.Empty(t, scope.Projects) assert.Empty(t, scope.Regions) assert.Empty(t, scope.Accounts) } func TestParseCloudScope_ValidJSON(t *testing.T) { t.Parallel() - scope, err := parseCloudScope(`{"projects":["prod"],"regions":["us-central1"]}`) + scope, err := parseCloudScope(`{"regions":["us-central1"],"accounts":["123456789012"]}`) require.NoError(t, err) - assert.Equal(t, []string{"prod"}, scope.Projects) 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) { diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index b3f6a957..b5588a16 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -140,11 +140,11 @@ Give every account's role the same read-only permission and trust policies shown ## 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). 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. +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, which is why AWS needs the `accounts` list and GCP does not: +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 in-scope 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 `scope.projects` (or, when that axis is empty, the projects `list_inventory` surfaces). +- **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. @@ -164,11 +164,15 @@ 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 - # 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. + # 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: - projects: [prod-platform, prod-data] - regions: [us-central1, us-east1] + 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 @@ -181,11 +185,12 @@ cloud: # 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}; a single-account source is a one-entry list. + # 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"} + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly", tags: [prod, payments]} scope: - regions: [eu-west-1] # enforced on run_cli argv. + regions: [eu-west-1] # --region / --zone enforced on run_cli argv. accounts: ["123456789012"] # informational scope note; distinct from the source-level accounts list. ``` @@ -195,24 +200,25 @@ The fields: - `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}`, 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. -- `scope` — the target allowlist (see below). +- `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 which cloud targets the agent may reach, so it cannot pivot to an un-allowlisted project or region. Region/zone is enforced against `run_cli` argv. Project is not an argv axis: `--project` is deny-floored and the project is chosen with `set_active_target`, validated against `scope.projects`. Account reach is governed by the pinned role or profile, not by argv. +`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: - projects: [prod-platform] # gcp projects the agent may set_active_target to 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) `projects` or `regions` axis is unconstrained on that axis. A non-empty `regions` is a closed set: a `--region` or `--zone` value outside it fails validation before the command runs. A non-empty `projects` is the closed set `set_active_target` will accept. +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 an in-scope 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 therefore comes from the pinned identity's IAM, not from scope: grant the read-only roles only on the in-scope projects, as the setup above does, 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. +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. diff --git a/docs/content/profiles.md b/docs/content/profiles.md index 2a275f27..0bb92bec 100644 --- a/docs/content/profiles.md +++ b/docs/content/profiles.md @@ -70,9 +70,10 @@ cloud: - alias: prod-gcp provider: gcp assumed_identity: triage-readonly@prod.iam.gserviceaccount.com + projects: + - {id: prod-platform, tags: [prod, payments]} scope: - projects: [prod-platform] - regions: [us-central1] + regions: [us-central1] # Authentication for cluster access. Two kinds: # kubeconfig — reads $KUBECONFIG / ~/.kube/config. Zero setup. @@ -401,14 +402,15 @@ 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: - projects: [prod-platform] - regions: [us-central1] + 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 entry per account; single-account is a one-entry list - - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly"} + 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] diff --git a/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index 417b9aa5..80c6a777 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -220,6 +220,13 @@ func cloudSourceEnv(src profile.CloudSource) (map[string]string, error) { 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 { diff --git a/internal/preflight/mcpconfig_test.go b/internal/preflight/mcpconfig_test.go index b5cbc375..1ce2a80e 100644 --- a/internal/preflight/mcpconfig_test.go +++ b/internal/preflight/mcpconfig_test.go @@ -404,7 +404,7 @@ func TestWriteMCPConfig_GCPCloudSource_RegistersServerWithImpersonationEnv(t *te Alias: "prod-gcp", Provider: "gcp", AssumedIdentity: "triage-ro@prod.iam.gserviceaccount.com", - Scope: cloud.ScopeAllowlist{Projects: []string{"prod-a"}}, + Projects: []profile.CloudProject{{ID: "prod-a", Tags: []string{"prod"}}}, CommandAllowlistPath: "/etc/triagent/gcp-allow.json", }} path, err := writeMCPConfig(in) @@ -429,11 +429,14 @@ func TestWriteMCPConfig_GCPCloudSource_RegistersServerWithImpersonationEnv(t *te // AWS-specific env must not leak onto a gcp source. assert.NotContains(t, env, aws.EnvProfile) - rawScope, _ := env[cloud.EnvScope].(string) - require.NotEmpty(t, rawScope, "scope must be JSON-encoded into the env") - var scope cloud.ScopeAllowlist - require.NoError(t, json.Unmarshal([]byte(rawScope), &scope)) - assert.Equal(t, []string{"prod-a"}, scope.Projects) + // 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) { diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 87b41e36..bdf58fa5 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -172,10 +172,14 @@ type CloudSource struct { // 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} 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"` + // 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 @@ -184,12 +188,21 @@ type CloudSource struct { CommandAllowlistPath string `yaml:"command_allowlist_path,omitempty"` } -// CloudAccount is one aws account in a multi-account cloud source: the account -// id the agent selects by, and the read-only role_arn triagent assumes into it -// from the source's SourceProfile. +// 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"` + 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 { diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 6491a2f0..10e58004 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -307,6 +307,51 @@ func TestValidateCloudAWSMissingAccounts(t *testing.T) { 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() @@ -885,16 +930,15 @@ cloud: - alias: prod-gcp provider: gcp assumed_identity: triage-ro@prod.iam.gserviceaccount.com - scope: - projects: - - prod-a - - prod-b + 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"} + - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-ro", tags: [prod, analytics]} scope: regions: - us-east-1 @@ -907,7 +951,11 @@ cloud: assert.Equal(t, "prod-gcp", gcp.Alias) assert.Equal(t, "gcp", gcp.Provider) assert.Equal(t, "triage-ro@prod.iam.gserviceaccount.com", gcp.AssumedIdentity) - assert.Equal(t, []string{"prod-a", "prod-b"}, gcp.Scope.Projects) + 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] @@ -917,5 +965,6 @@ cloud: 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 84538f65..6f2d96e2 100644 --- a/internal/profile/profiles/default/profile.yaml +++ b/internal/profile/profiles/default/profile.yaml @@ -213,14 +213,15 @@ extra_mcps: [] # - 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: -# projects: [prod-platform] -# regions: [us-central1] +# 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"} +# - {account_id: "123456789012", role_arn: "arn:aws:iam::123456789012:role/triage-readonly", tags: [prod, payments]} # scope: # accounts: ["123456789012"] # regions: [eu-west-1] diff --git a/internal/profile/validate.go b/internal/profile/validate.go index 1ffb42cb..210b7abf 100644 --- a/internal/profile/validate.go +++ b/internal/profile/validate.go @@ -80,10 +80,17 @@ func (p *Profile) Validate() error { 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)) @@ -98,6 +105,24 @@ func (p *Profile) Validate() error { 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 diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index 92a58be2..2cd1d6b9 100644 --- a/internal/server/handlers_connections.go +++ b/internal/server/handlers_connections.go @@ -113,7 +113,10 @@ func (a *apiHandlers) cloudConnections(ctx context.Context) []cloudConnection { if conn.AssumedIdentity == "" { conn.AssumedIdentity = src.AssumedIdentity } - conn.Projects = src.Scope.Projects + conn.Projects = make([]string, 0, len(src.Projects)) + for _, pr := range src.Projects { + conn.Projects = append(conn.Projects, pr.ID) + } } out = append(out, conn) } diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go index a9aa20a8..972279cd 100644 --- a/internal/server/handlers_connections_test.go +++ b/internal/server/handlers_connections_test.go @@ -292,7 +292,7 @@ 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", Scope: cloud.ScopeAllowlist{Projects: []string{"prod-platform", "prod-data"}}}, + {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"}, diff --git a/pkg/mcp/cloud/env.go b/pkg/mcp/cloud/env.go index 505716ff..de4cf2ef 100644 --- a/pkg/mcp/cloud/env.go +++ b/pkg/mcp/cloud/env.go @@ -20,10 +20,16 @@ const ( // threads it into the identity probe; the provider validates the resolved // identity against it. EnvExpectedIdentity = "TRIAGENT_CLOUD_EXPECTED_IDENTITY" - // EnvAWSAccounts carries the aws multi-account set as a JSON array of - // {account_id, role_arn} objects. The serve subprocess decodes it and builds - // the aws provider's configured targets and generated assume-role profiles. - // Empty for gcp and for the single-account aws form. + // 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. diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go index f1213d17..649c3c36 100644 --- a/pkg/mcp/cloud/provider.go +++ b/pkg/mcp/cloud/provider.go @@ -59,10 +59,13 @@ type Provider interface { } // Target is one selectable project (gcp) or account (aws) the agent may make -// active via set_active_target. +// 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"` + 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 @@ -75,10 +78,13 @@ type Inventory struct { Scopes []Scope `json:"scopes"` } -// Scope is one project (gcp) or account (aws) the pinned identity can read. +// 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"` + ID string `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags,omitempty"` } // IdentityStatus is the single struct the identity probe returns. The diff --git a/pkg/mcp/cloud/providers/aws/inventory.go b/pkg/mcp/cloud/providers/aws/inventory.go index 93e6136c..c50b0695 100644 --- a/pkg/mcp/cloud/providers/aws/inventory.go +++ b/pkg/mcp/cloud/providers/aws/inventory.go @@ -14,7 +14,7 @@ import ( 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}) + 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 index 8b9c72b3..769756bc 100644 --- a/pkg/mcp/cloud/providers/aws/inventory_test.go +++ b/pkg/mcp/cloud/providers/aws/inventory_test.go @@ -32,6 +32,22 @@ func TestInventoryUsesConfiguredAccounts(t *testing.T) { 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) { diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go index 7ee35a9d..e8d766ad 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -45,11 +45,13 @@ var _ cloud.Provider = (*Provider)(nil) // 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, and the read-only role_arn triagent generates an assume-role -// profile for, layered over the source's SSO base. +// 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 @@ -170,7 +172,7 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { 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}) + out = append(out, cloud.Target{ID: a.ID, Name: a.ID, Tags: a.Tags}) } return out } diff --git a/pkg/mcp/cloud/providers/gcp/inventory.go b/pkg/mcp/cloud/providers/gcp/inventory.go index ff36933b..95a1933f 100644 --- a/pkg/mcp/cloud/providers/gcp/inventory.go +++ b/pkg/mcp/cloud/providers/gcp/inventory.go @@ -15,12 +15,21 @@ type project struct { Name string `json:"name"` } -// Inventory lists the projects the pinned identity can read, projected to id + -// name. It is called with the server's validated RunFunc, so the argv must match -// the allowlisted `projects list` verb chain exactly. A run error here is a real -// failure of the inventory tool and is returned to the caller, unlike the -// identity probe which degrades. +// 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) diff --git a/pkg/mcp/cloud/providers/gcp/inventory_test.go b/pkg/mcp/cloud/providers/gcp/inventory_test.go index 77fe8c08..84c34b22 100644 --- a/pkg/mcp/cloud/providers/gcp/inventory_test.go +++ b/pkg/mcp/cloud/providers/gcp/inventory_test.go @@ -32,6 +32,45 @@ func runReturning(out string) cloud.RunFunc { } } +// 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") diff --git a/pkg/mcp/cloud/providers/gcp/provider.go b/pkg/mcp/cloud/providers/gcp/provider.go index 1db975b0..14b13210 100644 --- a/pkg/mcp/cloud/providers/gcp/provider.go +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -31,10 +31,28 @@ const EnvImpersonate = "CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT" var _ cloud.Provider = (*Provider)(nil) -// Provider implements cloud.Provider over the gcloud CLI. +// 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 @@ -42,7 +60,7 @@ type Provider struct { // 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() (*Provider, error) { +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) @@ -51,17 +69,22 @@ func New() (*Provider, error) { if err != nil { return nil, fmt.Errorf("gcp: resolve gcloud binary to absolute path: %w", err) } - return newWithBinary(abs) + 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. -func newWithBinary(binary string) (*Provider, error) { +// 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) } - return &Provider{binary: binary, allowlist: &list}, nil + 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. @@ -97,9 +120,17 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { } } -// ConfiguredTargets is empty for gcp: the selectable set comes from the -// server's scope projects or live inventory, not the provider's own config. -func (p *Provider) ConfiguredTargets() []cloud.Target { return nil } +// 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 diff --git a/pkg/mcp/cloud/providers/registry.go b/pkg/mcp/cloud/providers/registry.go index 027f0c06..b664cee2 100644 --- a/pkg/mcp/cloud/providers/registry.go +++ b/pkg/mcp/cloud/providers/registry.go @@ -15,15 +15,17 @@ import ( "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp" ) -// Options carries the multi-account config the aws provider needs from the -// profile's cloud source: the source alias (the generated profiles' namespace), -// the operator's SSO source_profile, and the account set. gcp ignores it. The -// zero value is the single-account / single-identity form, so callers that do -// not configure accounts call New(name) unchanged. +// 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 } // New constructs the cloud.Provider for the named provider ("gcp" | "aws"), @@ -40,7 +42,7 @@ func New(name string, opts ...Options) (cloud.Provider, error) { } switch name { case "gcp": - return gcp.New() + return gcp.New(gcp.Options{Projects: o.GCPProjects}) case "aws": return aws.New(aws.Options{ Alias: o.AWSAlias, diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index ea61f0c3..57f465c0 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -84,19 +84,12 @@ func New(opts Options) (*Server, error) { } // selectableTargets returns the set the agent may choose from: the provider's -// configured targets (aws accounts) when present, else the scope projects, else -// (unconstrained) the live inventory scopes. +// 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 } - if len(s.scope.Projects) > 0 { - out := make([]Target, 0, len(s.scope.Projects)) - for _, p := range s.scope.Projects { - out = append(out, Target{ID: p, Name: p}) - } - return out - } inv, err := s.provider.Inventory(ctx, s.runValidated) if err != nil { return nil diff --git a/pkg/mcp/cloud/server_test.go b/pkg/mcp/cloud/server_test.go index 29ef0c2f..0e3e6f86 100644 --- a/pkg/mcp/cloud/server_test.go +++ b/pkg/mcp/cloud/server_test.go @@ -32,13 +32,12 @@ func TestSelectableTargetsPrefersConfigured(t *testing.T) { assert.Equal(t, []Target{{ID: "acct-1", Name: "one"}}, got) } -func TestSelectableTargetsFallsBackToScopeProjects(t *testing.T) { +func TestSelectableTargetsCarriesTags(t *testing.T) { t.Parallel() - s := newTestServer(t, &fakeProvider{}, func(o *Options) { - o.Scope = ScopeAllowlist{Projects: []string{"prod", "staging"}} - }) + 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: "prod", Name: "prod"}, {ID: "staging", Name: "staging"}}, got) + assert.Equal(t, []Target{{ID: "acct-1", Name: "one", Tags: []string{"prod", "payments"}}}, got) } func TestSelectableTargetsFallsBackToInventory(t *testing.T) { diff --git a/pkg/mcp/cloud/validate.go b/pkg/mcp/cloud/validate.go index e052f094..2e2f8391 100644 --- a/pkg/mcp/cloud/validate.go +++ b/pkg/mcp/cloud/validate.go @@ -5,16 +5,15 @@ import ( "strings" ) -// ScopeAllowlist is the deployment's set of cloud targets any run_cli argv may -// reference. An empty field means that target axis is unconstrained. Region/zone -// is enforced here against argv (allowedFor maps --region/--zone). Project is not -// an argv axis: --project sits on the deny floor and the project is chosen only -// via set_active_target, validated against Projects there. Account reach is also -// not validated at the argv layer: it is constrained by the pinned identity or -// role the session assumes, and the identity-selecting flags (--account, -// --profile) sit on the deny floor. +// 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 { - Projects []string `json:"projects,omitempty"` Accounts []string `json:"accounts,omitempty"` Regions []string `json:"regions,omitempty"` } diff --git a/pkg/mcp/cloud/validate_test.go b/pkg/mcp/cloud/validate_test.go index 6addd8ed..e00e6289 100644 --- a/pkg/mcp/cloud/validate_test.go +++ b/pkg/mcp/cloud/validate_test.go @@ -9,7 +9,7 @@ import ( func TestValidateArgvRejectsDenyFloorAndScope(t *testing.T) { t.Parallel() al := &CommandAllowlist{Commands: []Command{{Path: "compute instances list"}}} - scope := ScopeAllowlist{Projects: []string{"prod"}, Regions: []string{"us-central1"}} + scope := ScopeAllowlist{Regions: []string{"us-central1"}} cases := []struct { name string argv []string @@ -71,7 +71,7 @@ func TestValidateArgvEqualsFormFlag(t *testing.T) { func TestValidateArgvAllowsResourceOperand(t *testing.T) { t.Parallel() al := &CommandAllowlist{Commands: []Command{{Path: "compute instances describe"}}} - scope := ScopeAllowlist{Projects: []string{"prod"}} + 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( From e4162ec08c17247fa91c54faf6be7f96bbc17b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:55:59 +0200 Subject: [PATCH 50/76] docs(mcp/cloud): list_inventory description surfaces deployment tags The output schema is not introspected into the tool spec, so the tool description is the agent's only prompt-time signal that each inventory entry carries the deployment's free-form tags. Say so, and guard it with a test. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/tools_inventory.go | 2 +- pkg/mcp/cloud/tools_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/mcp/cloud/tools_inventory.go b/pkg/mcp/cloud/tools_inventory.go index b5e7bb90..6d2cbc57 100644 --- a/pkg/mcp/cloud/tools_inventory.go +++ b/pkg/mcp/cloud/tools_inventory.go @@ -7,7 +7,7 @@ import ( "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. Read-only." +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. diff --git a/pkg/mcp/cloud/tools_test.go b/pkg/mcp/cloud/tools_test.go index 897ab8ec..9d417219 100644 --- a/pkg/mcp/cloud/tools_test.go +++ b/pkg/mcp/cloud/tools_test.go @@ -76,6 +76,14 @@ func TestListInventoryReturnsProviderScopes(t *testing.T) { 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{ From ed0ba970f88e77452811e917b97fa35687276bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:58:53 +0200 Subject: [PATCH 51/76] feat(server): surface cloud-context MCP wiring on the investigation DTO The MCP status bar derives its chip list purely from the investigation DTO, which carried no cloud information, so the cloud-context servers were invisible there. Derive the wired set from the session's profile cloud sources (prefixed into their triagent-cloud- wire aliases, tagged with provider) so the frontend can render them like every other chip. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/server/manager.go | 33 +++++++++++++++++++++++++++++++++ internal/server/manager_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+) 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) From 306c772c9a9ffdda814592eb76da3db7d054b3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:01:25 +0200 Subject: [PATCH 52/76] feat(web): render cloud-context MCPs in the action toolbar activeMCPs derived its chip list from the investigation DTO, which now carries the wired cloud sources, so emit a chip per cloud MCP under a new 'cloud' category with its own (orange) styling. Brings the status bar in line with the servers actually wired into the session. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/lib/api.ts | 12 ++++++++++ frontend/lib/mcps.test.ts | 49 +++++++++++++++++++++++++++++++++++++++ frontend/lib/mcps.ts | 12 +++++++++- 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 frontend/lib/mcps.test.ts diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 5b8fa089..0b438f2f 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -114,6 +114,15 @@ export type CloudConnection = { 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 = { id: string; name: string; @@ -470,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/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"; } } From fa65e3d429b0d9578230804f76b26354dc96e4e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:01:52 +0200 Subject: [PATCH 53/76] refactor(web): cap the mcp status bar height and let it scroll The chip row grew unbounded as MCP servers multiplied (cloud sources, per-repo git instances), pushing the rest of the panel down. Bound it to roughly three rows and scroll past that; the header stays fixed above. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/MCPStatusBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/MCPStatusBar.tsx b/frontend/components/MCPStatusBar.tsx index 83d8b907..0461b663 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]; From 7b606c43c6ed5b5b2ab96b5b418340b82ae11f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:04:27 +0200 Subject: [PATCH 54/76] feat(web): brand AWS/GCP marks on cloud connection pills The cloud pill labelled its provider with plain uppercase text. Render the provider's brand mark instead (new AwsIcon/GcpIcon, brand-suggestive like the existing Slack/incident.io marks), leading the alias. The provider name stays in the icon title/aria-label so it remains announced. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/ConnectionsPanel.tsx | 33 ++++++++++++-- frontend/components/Icons.tsx | 56 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/frontend/components/ConnectionsPanel.tsx b/frontend/components/ConnectionsPanel.tsx index fa4c2fdb..3eaccab1 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/ConnectionsPanel.tsx @@ -8,7 +8,14 @@ import { type ConnectionStatus, } from "@/lib/api"; import { useDialog } from "@/lib/dialog"; -import { CheckIcon, CloudIcon, 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 @@ -233,6 +240,26 @@ function CloudConnectionsSection({ cloud }: { cloud: CloudConnection[] }) { ); } +// 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.provider} -
{conn.valid ? ( + + + + ); +} + +// 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. From d7b344380e449a5f0050fcbe25fc38d072a0d603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:05:50 +0200 Subject: [PATCH 55/76] refactor(web): trim cloud connections copy, fold reauth into the pill The cloud section led with a six-line paragraph that restated setup and reauth advice for every operator on every open. Cut it to one line and a docs pointer; surface the provider-specific reauth command on a pill only when its identity is actually stale, where it is relevant. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/ConnectionsPanel.tsx | 25 ++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/components/ConnectionsPanel.tsx b/frontend/components/ConnectionsPanel.tsx index 3eaccab1..f069dbce 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/ConnectionsPanel.tsx @@ -222,14 +222,9 @@ function CloudConnectionsSection({ cloud }: { cloud: CloudConnection[] }) { cloud

- Read-only cloud identities pinned in the deployment profile’s{" "} - cloud: block, not - entered here. The service-account impersonation (GCP) or assume-role - profile (AWS) is configured there; see the Cloud providers docs page for - setup. Fix a stale credential through your own cloud login ( - gcloud auth login,{" "} - aws sso login) before - starting a session. + Read-only identities pinned in the profile’s{" "} + cloud: block, not edited + here. See the Cloud providers docs for setup.

{cloud.map((c) => ( @@ -288,8 +283,10 @@ function CloudPill({ conn }: { conn: CloudConnection }) { )}
- {!conn.valid && conn.hint && ( -
{conn.hint}
+ {!conn.valid && ( +
+ {conn.hint ?? reauthHint(conn.provider)} +
)}
); @@ -334,6 +331,14 @@ 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; From 3a99faacc711fa901b0c5c4dc8d82574a56bf862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:08:04 +0200 Subject: [PATCH 56/76] docs(cloud): add visual references to the cloud-providers page The page was all prose: the six axes, the three-layer security model, the leaf-verb rule, and the deny floor were walls of text with no shape. Both doc renderers support mermaid and GFM tables, so add a three-gate flow diagram, an axes table, a leaf-verb-vs-group-path table, a deny-floor table, and a real allowlist JSON excerpt. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 60 ++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index b5588a16..da6408bf 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -6,9 +6,31 @@ Triagent optionally gives the agent read-only context from the cloud the cluster 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: inventory (which projects/accounts and resources it can see), reachability (VPCs, subnets, firewall rules, security groups, routes), permissions (IAM policies, roles, service accounts), cluster (GKE/EKS networking and node config), logs, and the audit trail. +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` | + +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"] +``` -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 (the command allowlist, the deny floor, and the read-only IAM grant on the pinned identity) each have to hold for a read to go through, and none of them can be widened by the agent. +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. ## The pinned identity @@ -228,9 +250,39 @@ Identity- and target-selecting flags (`--account`, `--profile`, `--project`) nev 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. -Allowlist entries must be complete leaf verbs, for example `compute instances list` or `ec2 describe-security-groups`, never an intermediate group path like `compute instances` or `ec2`. The allowlist matches an entry as a prefix of the command, so an intermediate entry would also admit its sibling verbs, including mutating ones (`compute instances delete`, `ec2 terminate-instances`). 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: 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. +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: -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. The floor covers 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`), and argument values beginning with `file://`, `fileb://`, `@`, `http://`, or `https://` (local-file read and SSRF vectors). A too-broad allowlist override cannot punch through it. +| 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//`. From 528135115efee6bb6ad2a3fa1fc74fabe7970270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:29:31 +0200 Subject: [PATCH 57/76] fix(server): rehydrate wires cloud MCPs from the live profile Manager.Restore() rebuilds investigations via loadInvestigation, which never sets inv.Profile, so a restored session carries a nil profile. On resume, rehydrate passed that nil profile to preflight; cloudSources(nil) returned nothing and the regenerated mcp.json dropped every cloud server, so the cloud MCP never spawned after a launcher restart. Thread the launcher's live profile (a.prof, the same value fresh sessions hold) into both the preflight options and the rebuilt session, and repopulate inv.Profile so the status bar can still derive cloud chips. This matches rehydrate's existing contract of re-deriving wiring from current launcher state. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/server/rehydrate.go | 9 +++-- internal/server/rehydrate_test.go | 55 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/internal/server/rehydrate.go b/internal/server/rehydrate.go index aa8a8fe4..e9f589ef 100644 --- a/internal/server/rehydrate.go +++ b/internal/server/rehydrate.go @@ -93,7 +93,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 +118,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 +140,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{ From c226b255cff8747b074e7f3a4b1d0c0594168e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:38:36 +0200 Subject: [PATCH 58/76] refactor(web): raise the mcp status bar max height by a third Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/components/MCPStatusBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/MCPStatusBar.tsx b/frontend/components/MCPStatusBar.tsx index 0461b663..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]; From 77bc61147a716459218b28a7ddcc52ce982efa75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:44:09 +0200 Subject: [PATCH 59/76] fix(web): make cloud-providers docs section routable The /docs/[section] route kept its own copy of the valid-section list, which had drifted from the docs view's and omitted cloud-providers, so clicking it failed validation and fell back to the overview section. Extract the section registry to a single source both the rail and the route import, and derive the route's validation set from it so the two can never diverge again. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/app/(main)/docs/[section]/client.tsx | 17 +---- frontend/components/DocsView.tsx | 60 +-------------- frontend/lib/docs-sections.test.ts | 14 ++++ frontend/lib/docs-sections.ts | 75 +++++++++++++++++++ 4 files changed, 92 insertions(+), 74 deletions(-) create mode 100644 frontend/lib/docs-sections.test.ts create mode 100644 frontend/lib/docs-sections.ts 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/DocsView.tsx b/frontend/components/DocsView.tsx index d36f40c0..be74ab7a 100644 --- a/frontend/components/DocsView.tsx +++ b/frontend/components/DocsView.tsx @@ -3,65 +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" | "cloud-providers" | "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: "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", - }, -]; +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/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); From 17a8489fe24bc8436e8b715f25e2e2fdbc474148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:51:58 +0200 Subject: [PATCH 60/76] docs(cloud): clarify GCP service-account host vs target projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GCP setup example bound roles on a single project (prod-platform) and never showed where the service account is created, while using a host project (prod) whose name looks like the target — so it was unclear which project owns the account and which projects it reads. Add the account creation step, name the host-vs-target distinction explicitly, and loop the binding over every project in the source's scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index da6408bf..b90a69dc 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -48,19 +48,33 @@ 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*. The service account needs read-only access on each project in the source's scope. The minimal set of predefined roles covering the default tool surface (inventory, reachability, IAM read, GKE, logs, audit): +That binding lets the operator *act as* the service account; it is separate from what the service account itself may *read*. Create the account once, 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 -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 prod-platform \ - --member="serviceAccount:$SA" --role="$role" + +# 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 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 ``` From 3abbf7e98b105e2e90628f2999de0a1973d9522a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:56:43 +0200 Subject: [PATCH 61/76] docs(cloud): give GCP setup a config sample to match AWS The AWS setup section showed a copyable cloud: YAML entry inline, but the GCP setup described the profile only in prose (assumed_identity), so a GCP operator had no sample to copy while an AWS operator did. A fresh-reader pass confirmed the gap: given only the GCP setup, a reader could attest just the assumed_identity line and had to guess the rest of the structure. Add a minimal GCP cloud: entry (alias, provider, assumed_identity, projects) where the profile is introduced, mirroring AWS so both setup sections read the same way. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index b90a69dc..28bb5b3f 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -80,7 +80,19 @@ done `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`. 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 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. From 394f491a2d67e3b48d108cfaa1c46a860818fae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:06:23 +0200 Subject: [PATCH 62/76] docs(cloud): raise cloud-providers page to OSS-grade for newcomers and SREs Ran the page past two fresh-reader personas (a newcomer operator and a production SRE) and closed exactly the gaps they hit: - newcomer: a decision rule, a Before-you-start prerequisites + ordered runbook stating where the profile lives and that the launcher must be restarted to apply it, a worked investigation example, a crisp success state, and an ordered degraded-source troubleshooting checklist. - SRE: a consolidated capability-boundary table (control -> mechanism -> hard/soft) with the region soft-control called out, a corrected cross-account AWS trust model (each role trusts the SSO identity source_profile resolves to, not its own account root) with an SSO permission-set principal example and ExternalId/PrincipalOrgID hardening, a mid-session credential-expiry note, and the audit story (the transcript records every run_cli argv; cloud audit logs the calls under the pinned identity). Both personas re-tested clean against the rewritten page. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 77 ++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 28bb5b3f..fa86c4f6 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -2,6 +2,8 @@ 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. @@ -17,6 +19,8 @@ When a cloud source is configured, the launcher registers a `triagent-cloud-` / `# END triagent-cloud-` markers. The block is rewritten idempotently and never touches profiles you authored yourself or another alias's block; 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 and trust policies shown above. 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. +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 @@ -312,11 +365,23 @@ Underneath the allowlist sits a hardcoded deny floor the config can never re-ena 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//`. -## Visible degrade +## 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: -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 cloud source. The connections panel shows the source unavailable with a re-auth hint, and the session starts with the source disabled and visibly marked unavailable. The Kubernetes investigation proceeds without the cloud axis. +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. -Re-authentication is the operator's own cloud login (`gcloud auth login`, `aws sso login`), not anything entered in Triagent. The probe runs on connections-panel load so the operator can fix a stale credential before starting a session rather than discovering a degraded one mid-investigation. +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 From 216adaa1a22b0c5afe85331b7f859c9948b9acd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:16:55 +0200 Subject: [PATCH 63/76] docs(cloud): show the GCP impersonation-grant command, not just prose The GCP setup named roles/iam.serviceAccountTokenCreator in prose but never gave the command, and didn't make the binding-on-the-service-account vs binding-on-the-project distinction concrete. A real operator hit PERMISSION_DENIED on iam.serviceAccounts.getAccessToken because of exactly that gap. Add the impersonation grant (iam service-accounts add-iam-policy-binding ON the SA) to the setup block, label which bindings target the SA vs each project, and add a print-access-token verify step. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index fa86c4f6..9b8ba5f6 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -82,7 +82,7 @@ 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 once, then grant it read-only access on each project in the source's scope. +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. @@ -94,10 +94,22 @@ gcloud iam service-accounts create triage-readonly \ --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 target projects are independent of the host project above. +# 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 \ @@ -110,6 +122,9 @@ for project in prod-platform prod-data; do --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. From 38d08fa02d5cb53ba9bd41e8eae938275dd0b154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:20:51 +0200 Subject: [PATCH 64/76] docs(cloud): make AWS role setup runnable, matching GCP A fresh-reader pass found the AWS setup gave only policy JSON and prose, no aws iam commands, while GCP shipped runnable gcloud for create, grant, and verify. Add the command sequence that creates the role with its trust policy, attaches the read-only permissions (inline or managed ReadOnlyAccess), and verifies the assume with aws sts assume-role, plus a note to repeat per account for multi-account. The policies stay as JSON documents saved to the named files the commands consume, rather than duplicated into heredocs. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 9b8ba5f6..c8d80ba1 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -231,6 +231,35 @@ Scope the `Principal` down from the account root where you can. With AWS IAM Ide 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 From 8befcfe8437733bd5cfeb03f4fd7ce2eda35bb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:34:06 +0200 Subject: [PATCH 65/76] docs(spec): cloud-triage playbook design A type:general sub-flow (system/cloud_triage.yaml, prom_lookup-shaped) that encodes cloud-triage discipline: a gate node that refuses to query the cloud without a cloud-shaped signal, an orient node that pins project/account/region from cluster evidence before any read, and one lean investigate node over the read-only cloud MCP surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-02-cloud-triage-playbook-design.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-cloud-triage-playbook-design.md 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. From 6f956caa948cae5e6c70aeaf6cd8feddf66cea52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:37:35 +0200 Subject: [PATCH 66/76] feat(playbooks): add cloud_triage sub-flow with a signal gate A type:general sub-flow (prom_lookup-shaped) the agent walks when an investigation surfaces a cloud-shaped signal. It encodes the discipline the raw cloud MCP lacks: a gate node that refuses to query the cloud without an account/identity, cloud resource, or cloud permission signal (else the symptom is cluster-internal); an orient node that pins project/account/region from cluster evidence (node providerID/topology labels, workload-identity annotations) before any read; and one lean investigate node over the read-only cloud surface that correlates the finding back to the cluster symptom. Provider-agnostic; reads are bounded by the MCP allowlist, deny floor, and pinned read-only identity. embed_test validates it loads, has the expected nodes, and passes the strategies structural validator (entrypoint and every goto resolve). Co-Authored-By: Claude Opus 4.8 (1M context) --- system/cloud_triage.yaml | 188 +++++++++++++++++++++++++++++++++++++++ system/embed_test.go | 44 +++++++++ 2 files changed, 232 insertions(+) create mode 100644 system/cloud_triage.yaml 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 From 72a899a0db7832f3994256cd227aae126860ddca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:50:36 +0200 Subject: [PATCH 67/76] docs(cloud): align run_cli example and scope/identity docs with enforcement Addresses the open PR #53 review threads: - run_cli input-schema example copied a deny-floored --project, so an agent following it would be rejected; switch to an allowed --region example and state that target/identity flags are rejected. - default profile.yaml, profiles.md, and connections.md described scope as enforcing projects/accounts and AWS sources as having an assumed_identity. Only scope.regions is argv-enforced; projects/accounts are selected via the projects/accounts lists + set_active_target, and AWS pills render source_profile + accounts, not an assumed_identity. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/connections.md | 2 +- docs/content/profiles.md | 2 +- internal/profile/profiles/default/profile.yaml | 9 ++++++--- pkg/mcp/cloud/tools_cli.go | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/content/connections.md b/docs/content/connections.md index c225e819..e42ee961 100644 --- a/docs/content/connections.md +++ b/docs/content/connections.md @@ -65,7 +65,7 @@ incidents by passing a different `incident_id`. 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 pinned `assumed_identity` 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. +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. diff --git a/docs/content/profiles.md b/docs/content/profiles.md index 0bb92bec..df415788 100644 --- a/docs/content/profiles.md +++ b/docs/content/profiles.md @@ -395,7 +395,7 @@ 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`) — with a `scope` allowlist constraining which projects / accounts / regions any command may reference. +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: diff --git a/internal/profile/profiles/default/profile.yaml b/internal/profile/profiles/default/profile.yaml index 6f2d96e2..5009cfc3 100644 --- a/internal/profile/profiles/default/profile.yaml +++ b/internal/profile/profiles/default/profile.yaml @@ -200,9 +200,12 @@ extra_mcps: [] # `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` is the projects/accounts/regions allowlist -# any command may reference; an empty axis is unconstrained. -# `command_allowlist_path` overrides the provider's embedded read-only default. +# 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 diff --git a/pkg/mcp/cloud/tools_cli.go b/pkg/mcp/cloud/tools_cli.go index e0149b3e..0eab0881 100644 --- a/pkg/mcp/cloud/tools_cli.go +++ b/pkg/mcp/cloud/tools_cli.go @@ -15,7 +15,7 @@ const descListAllowedCommands = "List the provider CLI subcommands run_cli permi // 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 [\"compute\",\"firewall-rules\",\"list\",\"--project\",\"prod\"]). Do not include the binary name or pass a single string."` + 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 From c3780b0dd87e55cfd94adcf52309a2c31a1a43fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:04:40 +0200 Subject: [PATCH 68/76] fix(mcp/cloud): fail closed if AWS managed-profile write would be unparseable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stray fragment in ~/.aws/config was making the aws CLI reject the whole file, so every profile failed — including the operator's own base SSO, surfacing only as 'aws sts get-caller-identity failed'. The managed-profile writer is robust for all single-process inputs and is flock-serialized across processes, but the blast radius of landing one bad line is the entire file, so guard it: validate the merged config before atomicWrite and refuse (naming the offending line) rather than write something the CLI cannot parse. triagent never corrupts the operator's config, and a pre-existing bad line degrades with an actionable reason. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/providers/aws/profiles.go | 27 ++++++++++++++++++++ pkg/mcp/cloud/providers/aws/profiles_test.go | 22 ++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/pkg/mcp/cloud/providers/aws/profiles.go b/pkg/mcp/cloud/providers/aws/profiles.go index 16c23658..0d136162 100644 --- a/pkg/mcp/cloud/providers/aws/profiles.go +++ b/pkg/mcp/cloud/providers/aws/profiles.go @@ -76,6 +76,13 @@ func writeManagedProfiles(configPath, alias, sourceProfile string, accounts []Ac return fmt.Errorf("aws: read config %s: %w", configPath, err) } merged := replaceBlock(string(existing), begin, end, block.String()) + // Fail closed: never land content the aws CLI cannot parse. A single stray + // line breaks every profile in the file (including the operator's own base + // credentials), so refuse rather than write — surfacing the offending line + // instead of a downstream "sts get-caller-identity 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 it and retry", configPath, bad) + } return atomicWrite(configPath, []byte(merged)) } @@ -125,6 +132,26 @@ func replaceBlock(content, begin, end, block string) string { 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 { diff --git a/pkg/mcp/cloud/providers/aws/profiles_test.go b/pkg/mcp/cloud/providers/aws/profiles_test.go index 3c7febab..9ef6fa2d 100644 --- a/pkg/mcp/cloud/providers/aws/profiles_test.go +++ b/pkg/mcp/cloud/providers/aws/profiles_test.go @@ -92,6 +92,28 @@ func TestWriteManagedProfilesTwoAliases(t *testing.T) { assert.Equal(t, 1, strings.Count(got, "# BEGIN triagent-cloud-staging-aws")) } +// TestWriteManagedProfilesRefusesUnparseableConfig pins the fail-closed +// guarantee: triagent never lands content in ~/.aws/config that the aws CLI +// cannot parse. A single stray fragment outside any managed block (the +// corruption seen in the field) breaks every profile in the file, including the +// operator's own base credentials, so the write is refused and the file is left +// byte-for-byte untouched. +func TestWriteManagedProfilesRefusesUnparseableConfig(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config") + original := "[profile operator-test]\nregion = eu-west-1\n\n-test\n" + require.NoError(t, os.WriteFile(cfg, []byte(original), 0o600)) + + err := writeManagedProfiles(cfg, "aws-camunda", "operator-test", + []Account{{ID: "095352988152", RoleARN: "arn:aws:iam::095352988152:role/r"}}) + require.Error(t, err, "must refuse to write when the merged result is unparseable") + assert.Contains(t, err.Error(), "-test", "error should name the offending line") + + b, err := os.ReadFile(cfg) + require.NoError(t, err) + assert.Equal(t, original, string(b), "config must be untouched when the write is refused") +} + // TestWriteManagedProfilesConcurrentAliasesSurvive pins that concurrent // generation for different aliases into the same config file does not drop a // block: the read-modify-write is serialized, so every alias's managed section From 5ee148b5146f3fba7b026891ccb2d52e82056fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:22:21 +0200 Subject: [PATCH 69/76] docs(spec): per-profile AWS managed config file triagent will generate AWS assume-role profiles into a triagent-owned per-profile file (${XDG_CACHE_HOME}/triagent-mcp/${PROFILE_NAME}/aws/config) and point AWS_CONFIG_FILE at it, instead of editing the operator's shared ~/.aws/config in place. The file is a stripped copy of the operator's config plus a sentinel-delimited managed region, so source_profile still resolves while ~/.aws/config stays read-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-03-aws-owned-config-file-design.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-aws-owned-config-file-design.md 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..e6a18517 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-aws-owned-config-file-design.md @@ -0,0 +1,44 @@ +# 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. + +### Migration + +On launcher start, strip any `# BEGIN/END triagent-cloud-*` blocks triagent previously wrote into the operator's `~/.aws/config`, cleaning up cruft (including orphaned alias blocks) from the old in-place approach. + +### 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). From b461d161d40a4b3186e00a2df5b977335ba0c614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:24:32 +0200 Subject: [PATCH 70/76] feat(profile): per-profile CloudCacheDir path for the AWS managed config Adds Paths.CloudCacheDir (default ${XDG_CACHE_HOME}/triagent-mcp/${PROFILE_NAME}/aws), resolved and validated like GitCacheDir, so the AWS provider can write its generated config under the profile's own namespace instead of the operator's shared ~/.aws/config. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/profile/paths.go | 11 ++++++++++- internal/profile/paths_test.go | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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", } From 689d180670b10f211f75818a8a0abfed4f64be5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:31:43 +0200 Subject: [PATCH 71/76] feat(mcp/cloud): AWS provider writes a triagent-owned config, not ~/.aws/config writeManagedProfiles now takes explicit target + source paths. The target is a copy of the operator's config (with any stale triagent blocks stripped) plus a sentinel-delimited managed region holding the per-alias assume-role blocks, so source_profile resolves while the operator's ~/.aws/config is never written. New requires Options.ConfigTargetPath when accounts are configured and copies from ConfigSourcePath (defaulting to $HOME/.aws/config). Drops awsConfigPath (the old AWS_CONFIG_FILE-or-home resolver). Callers are threaded in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/providers/aws/profiles.go | 118 +++++++++---- pkg/mcp/cloud/providers/aws/profiles_test.go | 172 +++++++++++-------- pkg/mcp/cloud/providers/aws/provider.go | 40 ++++- pkg/mcp/cloud/providers/aws/provider_test.go | 16 +- 4 files changed, 221 insertions(+), 125 deletions(-) diff --git a/pkg/mcp/cloud/providers/aws/profiles.go b/pkg/mcp/cloud/providers/aws/profiles.go index 0d136162..6f9386b0 100644 --- a/pkg/mcp/cloud/providers/aws/profiles.go +++ b/pkg/mcp/cloud/providers/aws/profiles.go @@ -36,16 +36,22 @@ func blockMarkers(alias string) (begin, end string) { return "# BEGIN triagent-cloud-" + alias, "# END triagent-cloud-" + alias } -// writeManagedProfiles writes (or replaces) the managed assume-role profiles for -// one cloud source's accounts into configPath, atomically and idempotently. Each -// account gets a [profile triagent-cloud--] section layering -// its role_arn over sourceProfile (the operator's SSO base); triagent holds no -// credential — the aws CLI performs the assume-role from sourceProfile at run -// time. The section is bounded by # BEGIN/# END triagent-cloud- markers, -// so a rewrite replaces only that alias's block and leaves operator-authored -// profiles and other aliases' blocks untouched. Writing is tmp-file-then-rename -// so a crash never leaves a half-written config. -func writeManagedProfiles(configPath, alias, sourceProfile string, accounts []Account) error { +// 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 @@ -62,28 +68,82 @@ func writeManagedProfiles(configPath, alias, sourceProfile string, accounts []Ac managedProfilesMu.Lock() defer managedProfilesMu.Unlock() - if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { - return fmt.Errorf("aws: create config dir for %s: %w", configPath, err) + 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(configPath) + release, err := acquireConfigLock(targetPath) if err != nil { return err } defer release() - existing, err := os.ReadFile(configPath) + 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 config %s: %w", configPath, err) + return fmt.Errorf("aws: read managed config %s: %w", targetPath, err) } - merged := replaceBlock(string(existing), begin, end, block.String()) + + 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 (including the operator's own base - // credentials), so refuse rather than write — surfacing the offending line - // instead of a downstream "sts get-caller-identity failed". + // 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 it and retry", configPath, 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 a copy of the operator config never carries triagent's own blocks +// (e.g. ones an older in-place version wrote into ~/.aws/config). +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 atomicWrite(configPath, []byte(merged)) + 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 @@ -167,19 +227,3 @@ func atomicWrite(dst string, body []byte) error { } return nil } - -// awsConfigPath resolves the file writeManagedProfiles writes to: AWS_CONFIG_FILE -// when set (so a deployment or test can redirect it), else $HOME/.aws/config — -// the location the aws CLI reads profiles from. The empty return (no HOME, no -// override) signals the caller to skip generation rather than write to a -// surprising path. -func awsConfigPath() string { - if override := os.Getenv("AWS_CONFIG_FILE"); override != "" { - return override - } - home, err := os.UserHomeDir() - if err != nil || home == "" { - return "" - } - return filepath.Join(home, ".aws", "config") -} diff --git a/pkg/mcp/cloud/providers/aws/profiles_test.go b/pkg/mcp/cloud/providers/aws/profiles_test.go index 9ef6fa2d..55e3258d 100644 --- a/pkg/mcp/cloud/providers/aws/profiles_test.go +++ b/pkg/mcp/cloud/providers/aws/profiles_test.go @@ -15,112 +15,119 @@ func TestProfileName(t *testing.T) { assert.Equal(t, "triagent-cloud-prod-aws-111111111111", profileName("prod-aws", "111111111111")) } -func TestWriteManagedProfilesBlock(t *testing.T) { +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() - cfg := filepath.Join(dir, "config") + 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(cfg, "prod-aws", "sso-admin", accs)) + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "operator-test", accs)) - b, err := os.ReadFile(cfg) - require.NoError(t, err) - got := string(b) - assert.Contains(t, got, "# BEGIN triagent-cloud-prod-aws") - assert.Contains(t, got, "# END triagent-cloud-prod-aws") + 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, "role_arn = arn:aws:iam::111111111111:role/triage-readonly") - assert.Contains(t, got, "source_profile = sso-admin") + 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. +// replaces the prior block rather than appending a duplicate, and does not +// duplicate the operator copy. func TestWriteManagedProfilesIdempotent(t *testing.T) { dir := t.TempDir() - cfg := filepath.Join(dir, "config") + 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(cfg, "prod-aws", "sso-admin", accs)) - require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "sso-admin", accs)) + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "sso-admin", accs)) - b, err := os.ReadFile(cfg) - require.NoError(t, err) - got := string(b) + 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") } -// TestWriteManagedProfilesPreservesForeignContent proves the managed block is -// delimited: pre-existing operator profiles outside it survive a rewrite. -func TestWriteManagedProfilesPreservesForeignContent(t *testing.T) { +// 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() - cfg := filepath.Join(dir, "config") - foreign := "[profile sso-admin]\nsso_start_url = https://example.awsapps.com/start\n\n" - require.NoError(t, os.WriteFile(cfg, []byte(foreign), 0o600)) - + 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(cfg, "prod-aws", "sso-admin", accs)) - require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-admin", accs)) + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "sso-admin", accs)) - b, err := os.ReadFile(cfg) - require.NoError(t, err) - got := string(b) + got := readFile(t, target) assert.Contains(t, got, "[profile sso-admin]") assert.Contains(t, got, "sso_start_url = https://example.awsapps.com/start") - assert.Equal(t, 1, strings.Count(got, "[profile sso-admin]"), "foreign content must not be duplicated") } -// TestWriteManagedProfilesTwoAliases proves two managed blocks for different -// aliases coexist: rewriting one leaves the other intact. -func TestWriteManagedProfilesTwoAliases(t *testing.T) { +// TestWriteManagedProfilesStripsStaleManagedBlocks proves an operator config +// that still carries an old in-place triagent block (from the pre-owned-file +// era) does not get that block duplicated into the target copy. +func TestWriteManagedProfilesStripsStaleManagedBlocks(t *testing.T) { dir := t.TempDir() - cfg := filepath.Join(dir, "config") - require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-prod", + 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", "stale managed block from the operator copy must be stripped") + 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(cfg, "staging-aws", "sso-staging", + require.NoError(t, writeManagedProfiles(target, src, "staging-aws", "base", []Account{{ID: "222222222222", RoleARN: "arn:aws:iam::222222222222:role/r"}})) - require.NoError(t, writeManagedProfiles(cfg, "prod-aws", "sso-prod", + require.NoError(t, writeManagedProfiles(target, src, "prod-aws", "base", []Account{{ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/r"}})) - b, err := os.ReadFile(cfg) - require.NoError(t, err) - got := string(b) - assert.Contains(t, got, "# BEGIN triagent-cloud-prod-aws") - assert.Contains(t, got, "# BEGIN triagent-cloud-staging-aws") + 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")) -} - -// TestWriteManagedProfilesRefusesUnparseableConfig pins the fail-closed -// guarantee: triagent never lands content in ~/.aws/config that the aws CLI -// cannot parse. A single stray fragment outside any managed block (the -// corruption seen in the field) breaks every profile in the file, including the -// operator's own base credentials, so the write is refused and the file is left -// byte-for-byte untouched. -func TestWriteManagedProfilesRefusesUnparseableConfig(t *testing.T) { - dir := t.TempDir() - cfg := filepath.Join(dir, "config") - original := "[profile operator-test]\nregion = eu-west-1\n\n-test\n" - require.NoError(t, os.WriteFile(cfg, []byte(original), 0o600)) - - err := writeManagedProfiles(cfg, "aws-camunda", "operator-test", - []Account{{ID: "095352988152", RoleARN: "arn:aws:iam::095352988152:role/r"}}) - require.Error(t, err, "must refuse to write when the merged result is unparseable") - assert.Contains(t, err.Error(), "-test", "error should name the offending line") - - b, err := os.ReadFile(cfg) - require.NoError(t, err) - assert.Equal(t, original, string(b), "config must be untouched when the write is refused") + assert.Equal(t, 1, strings.Count(got, "[profile base]")) } // TestWriteManagedProfilesConcurrentAliasesSurvive pins that concurrent -// generation for different aliases into the same config file does not drop a -// block: the read-modify-write is serialized, so every alias's managed section -// is present afterward. Without the lock, racing read-modify-writes clobber each -// other and a block goes missing. +// 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) { - cfg := filepath.Join(t.TempDir(), "config") + 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 @@ -128,19 +135,34 @@ func TestWriteManagedProfilesConcurrentAliasesSurvive(t *testing.T) { wg.Add(1) go func(a string) { defer wg.Done() - err := writeManagedProfiles(cfg, a, "sso-admin", []Account{ + assert.NoError(t, writeManagedProfiles(target, src, a, "sso-admin", []Account{ {ID: "111111111111", RoleARN: "arn:aws:iam::111111111111:role/" + a}, - }) - assert.NoError(t, err) + })) }(alias) } wg.Wait() - data, err := os.ReadFile(cfg) - require.NoError(t, err) - got := string(data) + 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 index e8d766ad..d51c4746 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -15,6 +15,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "os/exec" "path/filepath" @@ -63,6 +64,12 @@ 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 @@ -84,10 +91,12 @@ type Provider struct { // cannot reinterpret it against a different working directory. // // When opts carries accounts, New generates the per-account assume-role profiles -// into ~/.aws/config (or $AWS_CONFIG_FILE) before returning, so the profiles -// exist for both the serve subprocess and any launcher-side probe that runs the -// CLI under AWS_PROFILE. Generation is idempotent: repeated construction (serve -// and launcher both build the provider) replaces the alias's managed block. +// 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) { @@ -113,17 +122,32 @@ func newWithBinary(binary string, opts ...Options) (*Provider, error) { o = opts[0] } if len(o.Accounts) > 0 { - cfg := awsConfigPath() - if cfg == "" { - return nil, fmt.Errorf("aws: cannot resolve ~/.aws/config (no HOME and no AWS_CONFIG_FILE) to generate account profiles") + if o.ConfigTargetPath == "" { + return nil, fmt.Errorf("aws: ConfigTargetPath is required to generate account profiles") } - if err := writeManagedProfiles(cfg, o.Alias, o.SourceProfile, o.Accounts); err != nil { + 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" } diff --git a/pkg/mcp/cloud/providers/aws/provider_test.go b/pkg/mcp/cloud/providers/aws/provider_test.go index 39819fd3..87feb79e 100644 --- a/pkg/mcp/cloud/providers/aws/provider_test.go +++ b/pkg/mcp/cloud/providers/aws/provider_test.go @@ -242,13 +242,19 @@ func TestExpectedIdentityReturnsAccountRoleARN(t *testing.T) { assert.False(t, ok, "an account outside the set yields no expected identity") } -// providerWithAccounts builds an aws provider with a generated-profile config -// pointed at a temp AWS config file so construction's writeManagedProfiles call -// does not touch the developer's ~/.aws/config. +// 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() - t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "config")) - p, err := newWithBinary("/usr/bin/aws", Options{Alias: alias, SourceProfile: "sso-admin", Accounts: accs}) + 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 } From ff183f1e61d693f8af8b98398b5536d5ec1f8147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:44:30 +0200 Subject: [PATCH 72/76] feat(cloud): thread the per-profile AWS config target/source through wiring Wires the triagent-owned AWS config end to end: the launcher sets AWS_CONFIG_FILE=/config and TRIAGENT_CLOUD_AWS_SOURCE_CONFIG on the cloud subprocess (cloudSourceEnv); serve reads them into providers.Options; the launcher-side probe is built with NewCloudProbe (cacheDir) so it generates and reads the same per-profile file. CloudCacheDir flows from profile paths through server.Options and preflight.Options. The operator's ~/.aws/config is now only ever read, never written. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/triagent-mcp/serve.go | 2 ++ cmd/triagent/start.go | 2 ++ internal/preflight/mcpconfig.go | 36 +++++++++++++++++++++-- internal/preflight/mcpconfig_test.go | 9 ++++-- internal/preflight/preflight.go | 43 +++++++++++++++++++++------- internal/preflight/preflight_test.go | 8 ++++-- internal/server/handlers.go | 1 + internal/server/rehydrate.go | 1 + internal/server/server.go | 5 ++++ pkg/mcp/cloud/env.go | 9 ++++++ pkg/mcp/cloud/providers/probe.go | 15 +++++++++- pkg/mcp/cloud/providers/registry.go | 13 +++++++-- 12 files changed, 124 insertions(+), 20 deletions(-) diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index fcfd8f86..29d3f6ab 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -468,6 +468,8 @@ func runCloud(ctx context.Context, f serveFlags) error { AWSSourceProfile: os.Getenv(cloud.EnvAWSSourceProfile), AWSAccounts: accounts, GCPProjects: gcpProjects, + AWSConfigTarget: os.Getenv(cloud.EnvAWSConfigFile), + AWSConfigSource: os.Getenv(cloud.EnvAWSSourceConfig), }) if err != nil { return err 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/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index 80c6a777..6277493d 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -111,6 +111,7 @@ type mcpConfigInputs struct { // `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) @@ -193,7 +194,7 @@ func kubeEnv(in mcpConfigInputs) map[string]string { // 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) (map[string]string, error) { +func cloudSourceEnv(src profile.CloudSource, cloudCacheDir string) (map[string]string, error) { env := map[string]string{ cloud.EnvProvider: src.Provider, } @@ -235,10 +236,41 @@ func cloudSourceEnv(src profile.CloudSource) (map[string]string, error) { 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). @@ -386,7 +418,7 @@ func writeMCPConfig(in mcpConfigInputs) (string, error) { for _, src := range in.CloudSources { alias := MCPAliasCloudPrefix + src.Alias - cloudEnv, err := cloudSourceEnv(src) + cloudEnv, err := cloudSourceEnv(src, in.CloudCacheDir) if err != nil { return "", err } diff --git a/internal/preflight/mcpconfig_test.go b/internal/preflight/mcpconfig_test.go index 1ce2a80e..52e63cbf 100644 --- a/internal/preflight/mcpconfig_test.go +++ b/internal/preflight/mcpconfig_test.go @@ -487,10 +487,14 @@ func TestCloudSourceEnv_AWSAccounts_EmitsAccountsAndSourceProfile(t *testing.T) {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 @@ -510,8 +514,9 @@ func TestCloudSourceEnv_GCP_CarriesNoAWSAccountsEnv(t *testing.T) { 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 49206a7c..95157839 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -44,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 @@ -177,7 +182,11 @@ func Run(opts Options) (*Result, error) { // 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) + 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, @@ -188,6 +197,7 @@ func Run(opts Options) (*Result, error) { LinkedRepos: opts.LinkedRepos, CloudSources: validCloudSources(cloudSources(opts.Profile), cloudStatuses), GitCacheDir: opts.GitCacheDir, + CloudCacheDir: opts.CloudCacheDir, UserPlaybooksDir: opts.UserPlaybooksDir, PluginPlaybooksDir: opts.PluginPlaybooksDir, SystemPlaybooksDir: opts.SystemPlaybooksDir, @@ -256,21 +266,32 @@ func probeCloudSources(ctx context.Context, sources []profile.CloudSource, probe return out } -// DefaultCloudProbe is the real prober: it maps a profile cloud source to the -// providers package's neutral Source 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. +// 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 providers.ProbeSource(ctx, cloudProbeSource(src)) + 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 rather than an empty AWS_PROFILE. -func cloudProbeSource(src profile.CloudSource) providers.Source { +// 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}) @@ -281,6 +302,8 @@ func cloudProbeSource(src profile.CloudSource) providers.Source { Alias: src.Alias, SourceProfile: src.SourceProfile, Accounts: accounts, + ConfigTarget: awsManagedConfigPath(cloudCacheDir), + ConfigSource: operatorAWSConfigPath(), } } diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go index b17a9b3f..f1e5adf9 100644 --- a/internal/preflight/preflight_test.go +++ b/internal/preflight/preflight_test.go @@ -314,7 +314,9 @@ func TestRun_CloudProviderConstructionErrorDegrades(t *testing.T) { // 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) { - t.Parallel() + // 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", @@ -325,7 +327,7 @@ func TestCloudProbeSource_ThreadsAWSMultiAccountFields(t *testing.T) { }, } - got := cloudProbeSource(src) + got := cloudProbeSource(src, "/cache/triagent-mcp/p/aws") assert.Equal(t, providers.Source{ Provider: "aws", @@ -335,5 +337,7 @@ func TestCloudProbeSource_ThreadsAWSMultiAccountFields(t *testing.T) { {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/server/handlers.go b/internal/server/handlers.go index b53e0094..1a41bcd3 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -427,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/rehydrate.go b/internal/server/rehydrate.go index e9f589ef..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, 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/env.go b/pkg/mcp/cloud/env.go index de4cf2ef..2b38a598 100644 --- a/pkg/mcp/cloud/env.go +++ b/pkg/mcp/cloud/env.go @@ -39,4 +39,13 @@ const ( // 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/providers/probe.go b/pkg/mcp/cloud/providers/probe.go index eac82407..1892c2e4 100644 --- a/pkg/mcp/cloud/providers/probe.go +++ b/pkg/mcp/cloud/providers/probe.go @@ -42,6 +42,11 @@ type Source struct { 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 @@ -60,6 +65,8 @@ func ProbeSource(ctx context.Context, src Source) cloud.IdentityStatus { AWSAlias: src.Alias, AWSSourceProfile: src.SourceProfile, AWSAccounts: src.Accounts, + AWSConfigTarget: src.ConfigTarget, + AWSConfigSource: src.ConfigSource, }) if err != nil { return cloud.IdentityStatus{ @@ -146,7 +153,13 @@ func credentialEnv(src Source) map[string]string { case "gcp": return map[string]string{gcp.EnvImpersonate: src.AssumedIdentity} case "aws": - return map[string]string{aws.EnvProfile: awsProbeProfile(src)} + 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 } diff --git a/pkg/mcp/cloud/providers/registry.go b/pkg/mcp/cloud/providers/registry.go index b664cee2..63c3a8d5 100644 --- a/pkg/mcp/cloud/providers/registry.go +++ b/pkg/mcp/cloud/providers/registry.go @@ -26,6 +26,11 @@ type Options struct { 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"), @@ -45,9 +50,11 @@ func New(name string, opts ...Options) (cloud.Provider, error) { return gcp.New(gcp.Options{Projects: o.GCPProjects}) case "aws": return aws.New(aws.Options{ - Alias: o.AWSAlias, - SourceProfile: o.AWSSourceProfile, - Accounts: o.AWSAccounts, + 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) From 32e59e6512d2e8902bf71bcc8ee60c46fb21db51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:47:28 +0200 Subject: [PATCH 73/76] feat(cloud): strip legacy triagent blocks from ~/.aws/config on start One-time cleanup now that the AWS config is generated separately: on launcher start, remove any # BEGIN/# END triagent-cloud-* blocks an older in-place version wrote into the operator's ~/.aws/config (including blocks orphaned by an alias rename). Preserves operator-authored profiles, is a no-op when absent or already clean, and writes atomically only on change. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/triagent/start.go | 24 +++++++++++ pkg/mcp/cloud/providers/aws/migrate.go | 39 +++++++++++++++++ pkg/mcp/cloud/providers/aws/migrate_test.go | 46 +++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 pkg/mcp/cloud/providers/aws/migrate.go create mode 100644 pkg/mcp/cloud/providers/aws/migrate_test.go diff --git a/cmd/triagent/start.go b/cmd/triagent/start.go index 56fe4fc9..1580e00f 100644 --- a/cmd/triagent/start.go +++ b/cmd/triagent/start.go @@ -15,6 +15,7 @@ import ( "github.com/sourcehawk/triagent/internal/preflight" "github.com/sourcehawk/triagent/internal/profile" "github.com/sourcehawk/triagent/internal/server" + "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" "github.com/sourcehawk/triagent/system" "github.com/spf13/cobra" ) @@ -87,6 +88,15 @@ func runStart(cmd *cobra.Command, f *startFlags) error { } warnLegacyUnnamespacedDirs(prof.Name, paths) + // One-time cleanup: older versions wrote triagent's assume-role profiles + // into the operator's ~/.aws/config in place. triagent now generates a + // separate owned config, so strip any blocks left in the operator's file. + if changed, err := aws.StripManagedBlocksFromConfig(operatorAWSConfigPath()); err != nil { + log.Warn("could not clean legacy triagent blocks from ~/.aws/config", "err", err) + } else if changed { + log.Info("removed legacy triagent-managed blocks from your ~/.aws/config (now generated separately)") + } + p, err := newAuthProvider(prof.Auth) if err != nil { return fmt.Errorf("auth provider: %w", err) @@ -188,6 +198,20 @@ func runStart(cmd *cobra.Command, f *startFlags) error { // them once so the operator can `mv` or `rm` deliberately. Profiles // whose paths don't bake in the profile-name segment skip the // check — they opted out of the namespacing. +// operatorAWSConfigPath is the operator's own AWS config the one-time migration +// cleans and the provider later copies from: $AWS_CONFIG_FILE when the operator +// set one, else $HOME/.aws/config. +func operatorAWSConfigPath() string { + if v := os.Getenv("AWS_CONFIG_FILE"); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".aws", "config") +} + func warnLegacyUnnamespacedDirs(profileName string, paths profile.Paths) { if profileName == "" { return diff --git a/pkg/mcp/cloud/providers/aws/migrate.go b/pkg/mcp/cloud/providers/aws/migrate.go new file mode 100644 index 00000000..d697946f --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/migrate.go @@ -0,0 +1,39 @@ +package aws + +import ( + "fmt" + "os" + "strings" +) + +// StripManagedBlocksFromConfig removes triagent's # BEGIN/# END +// triagent-cloud-* blocks from the operator's AWS config at path, a one-time +// cleanup of the old in-place approach now that triagent generates a separate +// owned config. It is a no-op (changed=false) when the file is absent or carries +// no managed blocks, and writes atomically only when it changed something, so it +// is safe to call on every launcher start. Operator-authored profiles outside +// the managed blocks are preserved. +func StripManagedBlocksFromConfig(path string) (bool, error) { + if path == "" { + return false, nil + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("aws: read %s: %w", path, err) + } + orig := string(b) + if !strings.Contains(orig, "# BEGIN triagent-cloud-") { + return false, nil + } + stripped := stripManagedBlocks(orig) + if stripped == orig { + return false, nil + } + if err := atomicWrite(path, []byte(stripped)); err != nil { + return false, err + } + return true, nil +} diff --git a/pkg/mcp/cloud/providers/aws/migrate_test.go b/pkg/mcp/cloud/providers/aws/migrate_test.go new file mode 100644 index 00000000..44b82e3f --- /dev/null +++ b/pkg/mcp/cloud/providers/aws/migrate_test.go @@ -0,0 +1,46 @@ +package aws + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripManagedBlocksFromConfig(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config") + body := "[profile operator-test]\nregion = eu-west-1\n\n" + + "# BEGIN triagent-cloud-camunda\n[profile triagent-cloud-camunda-1]\nrole_arn = arn\nsource_profile = operator-test\n# END triagent-cloud-camunda\n" + require.NoError(t, os.WriteFile(cfg, []byte(body), 0o600)) + + changed, err := StripManagedBlocksFromConfig(cfg) + require.NoError(t, err) + assert.True(t, changed, "a config with a managed block must be rewritten") + + got, err := os.ReadFile(cfg) + require.NoError(t, err) + assert.NotContains(t, string(got), "triagent-cloud-camunda", "managed block must be removed") + assert.Contains(t, string(got), "[profile operator-test]", "operator profiles must be preserved") + + // Idempotent: a second pass changes nothing. + changed, err = StripManagedBlocksFromConfig(cfg) + require.NoError(t, err) + assert.False(t, changed, "second pass must be a no-op") +} + +func TestStripManagedBlocksFromConfig_AbsentOrClean(t *testing.T) { + dir := t.TempDir() + + changed, err := StripManagedBlocksFromConfig(filepath.Join(dir, "missing")) + require.NoError(t, err) + assert.False(t, changed, "absent file is a no-op") + + clean := filepath.Join(dir, "config") + require.NoError(t, os.WriteFile(clean, []byte("[profile operator-test]\nregion = eu-west-1\n"), 0o600)) + changed, err = StripManagedBlocksFromConfig(clean) + require.NoError(t, err) + assert.False(t, changed, "config without managed blocks is a no-op") +} From 35ea92312526a525b948d5df9a7db1900371e45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:48:53 +0200 Subject: [PATCH 74/76] docs(cloud): document the triagent-owned AWS config triagent no longer edits ~/.aws/config; it generates a per-profile config under ${XDG_CACHE_HOME}/triagent-mcp//aws/config (a copy of the operator config plus the assume-role profiles) and points the cloud MCP at it via AWS_CONFIG_FILE. Update the Spanning-accounts section accordingly and note the one-time cleanup of legacy in-place blocks. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/cloud-providers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index c8d80ba1..556a49cf 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -153,7 +153,7 @@ The operator authenticates normally, for example: 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 `~/.aws/config` 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`. +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: @@ -277,7 +277,7 @@ cloud: - {account_id: "333333333333", role_arn: "arn:aws:iam::333333333333:role/triage-readonly"} ``` -Triagent writes the generated profiles into a managed block in your `~/.aws/config` (or `$AWS_CONFIG_FILE`) delimited by `# BEGIN triagent-cloud-` / `# END triagent-cloud-` markers. The block is rewritten idempotently and never touches profiles you authored yourself or another alias's block; triagent stores no credential, the AWS CLI performs the assume-role from your SSO base. +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. (Older versions wrote a managed block into `~/.aws/config` directly; the launcher strips any such leftover blocks on start.) 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. From d1595c04b92e203123ae6154c14592b4e01df7f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:03:41 +0200 Subject: [PATCH 75/76] fix(cloud): drop the ~/.aws/config migration; it cleans never-shipped state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-place managed-block approach never shipped on main, so a startup migration that strips blocks from the operator's ~/.aws/config guards a state no real deployment can have — and it reintroduces the very thing this change removes: the launcher writing to a user-owned file. Remove the migration (StripManagedBlocksFromConfig + the start-up call). Keep the in-copy stripManagedBlocks (it only shapes triagent's own generated file, never the operator's), reframed without the legacy wording. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/triagent/start.go | 24 ---------- docs/content/cloud-providers.md | 2 +- ...2026-06-03-aws-owned-config-file-design.md | 4 -- pkg/mcp/cloud/providers/aws/migrate.go | 39 ---------------- pkg/mcp/cloud/providers/aws/migrate_test.go | 46 ------------------- pkg/mcp/cloud/providers/aws/profiles.go | 6 ++- pkg/mcp/cloud/providers/aws/profiles_test.go | 10 ++-- 7 files changed, 10 insertions(+), 121 deletions(-) delete mode 100644 pkg/mcp/cloud/providers/aws/migrate.go delete mode 100644 pkg/mcp/cloud/providers/aws/migrate_test.go diff --git a/cmd/triagent/start.go b/cmd/triagent/start.go index 1580e00f..56fe4fc9 100644 --- a/cmd/triagent/start.go +++ b/cmd/triagent/start.go @@ -15,7 +15,6 @@ import ( "github.com/sourcehawk/triagent/internal/preflight" "github.com/sourcehawk/triagent/internal/profile" "github.com/sourcehawk/triagent/internal/server" - "github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws" "github.com/sourcehawk/triagent/system" "github.com/spf13/cobra" ) @@ -88,15 +87,6 @@ func runStart(cmd *cobra.Command, f *startFlags) error { } warnLegacyUnnamespacedDirs(prof.Name, paths) - // One-time cleanup: older versions wrote triagent's assume-role profiles - // into the operator's ~/.aws/config in place. triagent now generates a - // separate owned config, so strip any blocks left in the operator's file. - if changed, err := aws.StripManagedBlocksFromConfig(operatorAWSConfigPath()); err != nil { - log.Warn("could not clean legacy triagent blocks from ~/.aws/config", "err", err) - } else if changed { - log.Info("removed legacy triagent-managed blocks from your ~/.aws/config (now generated separately)") - } - p, err := newAuthProvider(prof.Auth) if err != nil { return fmt.Errorf("auth provider: %w", err) @@ -198,20 +188,6 @@ func runStart(cmd *cobra.Command, f *startFlags) error { // them once so the operator can `mv` or `rm` deliberately. Profiles // whose paths don't bake in the profile-name segment skip the // check — they opted out of the namespacing. -// operatorAWSConfigPath is the operator's own AWS config the one-time migration -// cleans and the provider later copies from: $AWS_CONFIG_FILE when the operator -// set one, else $HOME/.aws/config. -func operatorAWSConfigPath() string { - if v := os.Getenv("AWS_CONFIG_FILE"); v != "" { - return v - } - home, err := os.UserHomeDir() - if err != nil || home == "" { - return "" - } - return filepath.Join(home, ".aws", "config") -} - func warnLegacyUnnamespacedDirs(profileName string, paths profile.Paths) { if profileName == "" { return diff --git a/docs/content/cloud-providers.md b/docs/content/cloud-providers.md index 556a49cf..ac47a889 100644 --- a/docs/content/cloud-providers.md +++ b/docs/content/cloud-providers.md @@ -277,7 +277,7 @@ cloud: - {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. (Older versions wrote a managed block into `~/.aws/config` directly; the launcher strips any such leftover blocks on start.) +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. 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 index e6a18517..a31e606e 100644 --- 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 @@ -29,10 +29,6 @@ Copying the whole operator config makes the target self-contained, so `source_pr - `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. -### Migration - -On launcher start, strip any `# BEGIN/END triagent-cloud-*` blocks triagent previously wrote into the operator's `~/.aws/config`, cleaning up cruft (including orphaned alias blocks) from the old in-place approach. - ### 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. diff --git a/pkg/mcp/cloud/providers/aws/migrate.go b/pkg/mcp/cloud/providers/aws/migrate.go deleted file mode 100644 index d697946f..00000000 --- a/pkg/mcp/cloud/providers/aws/migrate.go +++ /dev/null @@ -1,39 +0,0 @@ -package aws - -import ( - "fmt" - "os" - "strings" -) - -// StripManagedBlocksFromConfig removes triagent's # BEGIN/# END -// triagent-cloud-* blocks from the operator's AWS config at path, a one-time -// cleanup of the old in-place approach now that triagent generates a separate -// owned config. It is a no-op (changed=false) when the file is absent or carries -// no managed blocks, and writes atomically only when it changed something, so it -// is safe to call on every launcher start. Operator-authored profiles outside -// the managed blocks are preserved. -func StripManagedBlocksFromConfig(path string) (bool, error) { - if path == "" { - return false, nil - } - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, fmt.Errorf("aws: read %s: %w", path, err) - } - orig := string(b) - if !strings.Contains(orig, "# BEGIN triagent-cloud-") { - return false, nil - } - stripped := stripManagedBlocks(orig) - if stripped == orig { - return false, nil - } - if err := atomicWrite(path, []byte(stripped)); err != nil { - return false, err - } - return true, nil -} diff --git a/pkg/mcp/cloud/providers/aws/migrate_test.go b/pkg/mcp/cloud/providers/aws/migrate_test.go deleted file mode 100644 index 44b82e3f..00000000 --- a/pkg/mcp/cloud/providers/aws/migrate_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package aws - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestStripManagedBlocksFromConfig(t *testing.T) { - dir := t.TempDir() - cfg := filepath.Join(dir, "config") - body := "[profile operator-test]\nregion = eu-west-1\n\n" + - "# BEGIN triagent-cloud-camunda\n[profile triagent-cloud-camunda-1]\nrole_arn = arn\nsource_profile = operator-test\n# END triagent-cloud-camunda\n" - require.NoError(t, os.WriteFile(cfg, []byte(body), 0o600)) - - changed, err := StripManagedBlocksFromConfig(cfg) - require.NoError(t, err) - assert.True(t, changed, "a config with a managed block must be rewritten") - - got, err := os.ReadFile(cfg) - require.NoError(t, err) - assert.NotContains(t, string(got), "triagent-cloud-camunda", "managed block must be removed") - assert.Contains(t, string(got), "[profile operator-test]", "operator profiles must be preserved") - - // Idempotent: a second pass changes nothing. - changed, err = StripManagedBlocksFromConfig(cfg) - require.NoError(t, err) - assert.False(t, changed, "second pass must be a no-op") -} - -func TestStripManagedBlocksFromConfig_AbsentOrClean(t *testing.T) { - dir := t.TempDir() - - changed, err := StripManagedBlocksFromConfig(filepath.Join(dir, "missing")) - require.NoError(t, err) - assert.False(t, changed, "absent file is a no-op") - - clean := filepath.Join(dir, "config") - require.NoError(t, os.WriteFile(clean, []byte("[profile operator-test]\nregion = eu-west-1\n"), 0o600)) - changed, err = StripManagedBlocksFromConfig(clean) - require.NoError(t, err) - assert.False(t, changed, "config without managed blocks is a no-op") -} diff --git a/pkg/mcp/cloud/providers/aws/profiles.go b/pkg/mcp/cloud/providers/aws/profiles.go index 6f9386b0..de7f8ac2 100644 --- a/pkg/mcp/cloud/providers/aws/profiles.go +++ b/pkg/mcp/cloud/providers/aws/profiles.go @@ -109,8 +109,10 @@ func managedRegion(content string) string { } // stripManagedBlocks removes every # BEGIN/# END triagent-cloud-* region from -// content, so a copy of the operator config never carries triagent's own blocks -// (e.g. ones an older in-place version wrote into ~/.aws/config). +// 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)) diff --git a/pkg/mcp/cloud/providers/aws/profiles_test.go b/pkg/mcp/cloud/providers/aws/profiles_test.go index 55e3258d..c0dec7b4 100644 --- a/pkg/mcp/cloud/providers/aws/profiles_test.go +++ b/pkg/mcp/cloud/providers/aws/profiles_test.go @@ -85,10 +85,10 @@ func TestWriteManagedProfilesCopiesOperatorConfig(t *testing.T) { assert.Contains(t, got, "sso_start_url = https://example.awsapps.com/start") } -// TestWriteManagedProfilesStripsStaleManagedBlocks proves an operator config -// that still carries an old in-place triagent block (from the pre-owned-file -// era) does not get that block duplicated into the target copy. -func TestWriteManagedProfilesStripsStaleManagedBlocks(t *testing.T) { +// 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") @@ -97,7 +97,7 @@ func TestWriteManagedProfilesStripsStaleManagedBlocks(t *testing.T) { []Account{{ID: "095352988152", RoleARN: "arn:aws:iam::095352988152:role/triagent-readonly"}})) got := readFile(t, target) - assert.NotContains(t, got, "triagent-cloud-old", "stale managed block from the operator copy must be stripped") + 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]") } From 186789fbfa40cddaaa086fe3f50dd3a6960e866f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:21:57 +0200 Subject: [PATCH 76/76] fix(cloud): correct AWS test shape and connections comment PR #53 review: cloud_base_test built an AWS source with assumed_identity, which profile validation forbids for aws (it requires source_profile + accounts); use the valid shape. And the cloudConnection comment referenced a non-existent scope.projects field, when projects come from the source's projects: selectable set, not scope; correct it. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/profile/cloud_base_test.go | 7 ++++++- internal/server/handlers_connections.go | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/profile/cloud_base_test.go b/internal/profile/cloud_base_test.go index 334e374f..a600537f 100644 --- a/internal/profile/cloud_base_test.go +++ b/internal/profile/cloud_base_test.go @@ -29,7 +29,12 @@ func TestApplyBase_InheritsCloudWhenOverrideOmits(t *testing.T) { func TestApplyBase_OverrideCloudWins(t *testing.T) { t.Parallel() override := &Profile{ - Cloud: []CloudSource{{Alias: "child-aws", Provider: "aws", AssumedIdentity: "arn:aws:iam::1:role/ro"}}, + 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"}}, diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go index 2cd1d6b9..20386afa 100644 --- a/internal/server/handlers_connections.go +++ b/internal/server/handlers_connections.go @@ -41,9 +41,10 @@ type cloudConnection struct { 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 scope.projects allowlist; - // empty means unconstrained). aws: SourceProfile (the operator's SSO base) - // over Accounts (the account ids the agent may select among). + // 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"`