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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/mcp/cloud/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type fakeProvider struct {
identity IdentityStatus
identityErr error
envPassthrough []string
targets []Target
}

func (f *fakeProvider) Name() string {
Expand Down Expand Up @@ -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} }
2 changes: 2 additions & 0 deletions pkg/mcp/cloud/probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions pkg/mcp/cloud/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions pkg/mcp/cloud/providers/aws/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions pkg/mcp/cloud/providers/gcp/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 7 additions & 5 deletions pkg/mcp/cloud/providers/probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
68 changes: 67 additions & 1 deletion pkg/mcp/cloud/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cloud

import (
"context"
"errors"
"fmt"
"os"
"strings"
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions pkg/mcp/cloud/server_test.go
Original file line number Diff line number Diff line change
@@ -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{})
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/mcp/cloud/specs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions pkg/mcp/cloud/tools_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
31 changes: 31 additions & 0 deletions pkg/mcp/cloud/tools_target.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading