diff --git a/README.md b/README.md index db3b17b..47b2dc7 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,21 @@ approval_policy = { granular = { sandbox_approval = false, rules = false, mcp_el approvals_reviewer = "user" ``` +For reliable native Codex tool discovery, prefer the `room-mcp-stdio` entrypoint +through a Codex plugin. It reads the agent token from a private +`ROOM_TOKEN_FILE`, so the Codex GUI process does not need to inherit a bearer +token environment variable. Because the stdio server is Codex's direct MCP +peer, Room form and URL elicitations render in the native Codex UI. + +```bash +go build -o ~/.local/bin/room-mcp-stdio ./cmd/room-mcp-stdio +``` + +The plugin MCP entry should invoke that binary with `ROOM_TOKEN_FILE`, +`ROOM_SERVER_URL`, and `ROOM_CONTROL_PLANE_URL` in its non-secret environment. +After installing or updating the plugin, start a new Codex task so its MCP tool +catalog is rebuilt from the plugin. + ## Hooks and CLI ```bash @@ -112,8 +127,10 @@ The ruleset cache is a private, scoped advisory cache. Evaluations are performed by Room and do not fall back to legacy local text heuristics when the server is unavailable. -Credential registry changes are loaded live. Reissuing an ID revokes its old -token without restarting `roomd` or `room-mcp`. +Credential registry changes are loaded live. Existing IDs cannot be reissued +through the bootstrap command; scope changes require the authenticated, +human-confirmed dashboard workflow, which rotates the token and records the +mutation receipt atomically without restarting `roomd` or `room-mcp`. See [architecture](docs/architecture.md), [analyzer contract](docs/analyzer.md), [rules](docs/rules.md), and [hooks](docs/hooks.md). diff --git a/cmd/room-mcp-stdio/main.go b/cmd/room-mcp-stdio/main.go new file mode 100644 index 0000000..56ade2a --- /dev/null +++ b/cmd/room-mcp-stdio/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/haasonsaas/room/internal/config" + roommcp "github.com/haasonsaas/room/internal/mcp" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + if err := run(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(ctx context.Context) error { + cfg := config.Load() + if err := cfg.ValidateClient(); err != nil { + return fmt.Errorf("invalid upstream configuration: %w", err) + } + if err := cfg.ValidateMCPControlPlane(); err != nil { + return fmt.Errorf("invalid MCP configuration: %w", err) + } + token, err := config.LoadTokenFile(cfg.TokenFile) + if err != nil { + return fmt.Errorf("load Room token: %w", err) + } + server := roommcp.NewServerWithTokenAndTimeout(cfg.ServerURL, cfg.ControlPlaneURL, token, cfg.ClientTimeout) + if err := server.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + return fmt.Errorf("serve Room MCP over stdio: %w", err) + } + return nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 49974f3..c7b714a 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -63,7 +63,7 @@ func TestIssueOrUpdateTokenStoresOnlyDigestAndAuthenticates(t *testing.T) { } } -func TestIssueOrUpdateTokenReplacesCredentialAndPreservesOthers(t *testing.T) { +func TestIssueOrUpdateTokenRejectsUnauditedReplacementAndPreservesOthers(t *testing.T) { path := filepath.Join(t.TempDir(), "credentials.json") admin := Principal{ID: "operator", Role: RoleAdmin} oldToken, err := IssueOrUpdateToken(path, admin) @@ -75,22 +75,15 @@ func TestIssueOrUpdateTokenReplacesCredentialAndPreservesOthers(t *testing.T) { if err != nil { t.Fatal(err) } - newToken, err := IssueOrUpdateToken(path, admin) - if err != nil { - t.Fatal(err) - } - if newToken == oldToken { - t.Fatal("updated credential reused a token") + if _, err := IssueOrUpdateToken(path, admin); err == nil { + t.Fatal("unaudited credential replacement succeeded") } registry, err := LoadRegistry(path) if err != nil { t.Fatal(err) } - if _, err := registry.Authenticate(oldToken); err != ErrUnauthenticated { - t.Fatalf("old token remains valid: %v", err) - } - if _, err := registry.Authenticate(newToken); err != nil { - t.Fatalf("new token: %v", err) + if _, err := registry.Authenticate(oldToken); err != nil { + t.Fatalf("rejected replacement revoked old token: %v", err) } if _, err := registry.Authenticate(agentToken); err != nil { t.Fatalf("preserved token: %v", err) @@ -120,6 +113,80 @@ func TestHumanOperatorCapabilityIsCredentialBound(t *testing.T) { } } +func TestRotateAgentScopeRequiresHumanApprovalAndPersistsReceiptAtomically(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + human := Principal{ID: "human-admin", Role: RoleAdmin, HumanOperator: true} + if _, err := IssueOrUpdateToken(path, human); err != nil { + t.Fatal(err) + } + oldScope := Scope{WorkspaceID: "local", Repository: "evalops/room", AgentID: "codex", HookProvider: HookProviderCodex} + oldToken, err := IssueOrUpdateToken(path, Principal{ID: "codex-local", Role: RoleAgent, Scope: oldScope}) + if err != nil { + t.Fatal(err) + } + newToken, receipt, err := RotateAgentScope(path, human, "codex-local", Scope{WorkspaceID: "local", Repository: "evalops/platform", AgentID: "codex"}, "APPROVE") + if err != nil { + t.Fatal(err) + } + if receipt.ActorID != human.ID || receipt.CredentialID != "codex-local" || receipt.Action != "rotate_agent_scope" || receipt.OldScope != oldScope || receipt.NewScope.Repository != "evalops/platform" || receipt.NewScope.HookProvider != HookProviderCodex { + t.Fatalf("receipt = %#v", receipt) + } + registry, err := LoadRegistry(path) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Authenticate(oldToken); err != ErrUnauthenticated { + t.Fatalf("old token remains valid: %v", err) + } + principal, err := registry.Authenticate(newToken) + if err != nil || principal.Scope != receipt.NewScope { + t.Fatalf("new principal = %#v, err = %v", principal, err) + } + receipts := registry.CredentialMutationReceipts() + if len(receipts) != 1 || receipts[0] != receipt { + t.Fatalf("persisted receipts = %#v", receipts) + } +} + +func TestRotateAgentScopeRejectsUnapprovedOrInexactMutationWithoutRevokingToken(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + human := Principal{ID: "human-admin", Role: RoleAdmin, HumanOperator: true} + if _, err := IssueOrUpdateToken(path, human); err != nil { + t.Fatal(err) + } + token, err := IssueOrUpdateToken(path, Principal{ID: "codex-local", Role: RoleAgent, Scope: Scope{WorkspaceID: "local", Repository: "evalops/room", AgentID: "codex"}}) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + actor Principal + scope Scope + confirmation string + }{ + {"non-human admin", Principal{ID: "automation", Role: RoleAdmin}, Scope{WorkspaceID: "local", Repository: "evalops/platform", AgentID: "codex"}, "APPROVE"}, + {"wrong confirmation", human, Scope{WorkspaceID: "local", Repository: "evalops/platform", AgentID: "codex"}, "approve"}, + {"wildcard repository", human, Scope{WorkspaceID: "local", Repository: "evalops/**", AgentID: "codex"}, "APPROVE"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, _, err := RotateAgentScope(path, tt.actor, "codex-local", tt.scope, tt.confirmation); err == nil { + t.Fatal("rotation succeeded") + } + registry, err := LoadRegistry(path) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Authenticate(token); err != nil { + t.Fatalf("rejected rotation revoked token: %v", err) + } + if len(registry.CredentialMutationReceipts()) != 0 { + t.Fatal("rejected rotation persisted a receipt") + } + }) + } +} + func TestLoadRegistryRejectsInvalidFiles(t *testing.T) { validDigest := strings.Repeat("a", 64) tests := []struct { diff --git a/internal/auth/registry.go b/internal/auth/registry.go index d58ff16..97f1c5a 100644 --- a/internal/auth/registry.go +++ b/internal/auth/registry.go @@ -17,6 +17,7 @@ import ( "sort" "strings" "sync/atomic" + "time" "golang.org/x/sys/unix" ) @@ -26,8 +27,21 @@ const registryVersion = 1 var credentialIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$`) type registryFile struct { - Version int `json:"version"` - Credentials []credentialFile `json:"credentials"` + Version int `json:"version"` + Credentials []credentialFile `json:"credentials"` + CredentialMutations []CredentialMutationReceipt `json:"credential_mutations,omitempty"` +} + +// CredentialMutationReceipt is persisted in the same atomic replacement as +// the credential mutation it records. +type CredentialMutationReceipt struct { + ID string `json:"id"` + ActorID string `json:"actor_id"` + CredentialID string `json:"credential_id"` + Action string `json:"action"` + OccurredAt time.Time `json:"occurred_at"` + OldScope Scope `json:"old_scope"` + NewScope Scope `json:"new_scope"` } type credentialFile struct { @@ -47,6 +61,7 @@ type credential struct { // Registry is an immutable, validated in-memory credential registry. type Registry struct { credentials []credential + mutations []CredentialMutationReceipt } // FileAuthenticator reloads a validated registry before every authentication. @@ -118,7 +133,7 @@ func decodeRegistry(reader io.Reader) (*Registry, error) { if stored.Version != registryVersion { return nil, fmt.Errorf("unsupported credential registry version %d", stored.Version) } - registry := &Registry{credentials: make([]credential, 0, len(stored.Credentials))} + registry := &Registry{credentials: make([]credential, 0, len(stored.Credentials)), mutations: append([]CredentialMutationReceipt(nil), stored.CredentialMutations...)} seen := make(map[string]struct{}, len(stored.Credentials)) for index, item := range stored.Credentials { if _, exists := seen[item.ID]; exists { @@ -201,7 +216,8 @@ func parseToken(token string) (string, []byte, bool) { return id, secret, true } -// IssueOrUpdateToken generates a credential and atomically replaces its registry entry. +// IssueOrUpdateToken bootstraps a new credential. Existing IDs must use an +// authenticated, audited rotation workflow. // The returned plaintext token is never persisted and should be displayed only once. func IssueOrUpdateToken(path string, principal Principal) (string, error) { if err := validatePrincipal(principal); err != nil { @@ -223,10 +239,11 @@ func IssueOrUpdateToken(path string, principal Principal) (string, error) { stored := registryFile{Version: registryVersion, Credentials: []credentialFile{}} registry, err := LoadRegistry(path) if err == nil { + stored.CredentialMutations = append([]CredentialMutationReceipt(nil), registry.mutations...) stored.Credentials = make([]credentialFile, 0, len(registry.credentials)+1) for _, existing := range registry.credentials { if existing.principal.ID == principal.ID { - continue + return "", errors.New("credential already exists; use an authenticated audited rotation workflow") } stored.Credentials = append(stored.Credentials, credentialFile{ ID: existing.principal.ID, Role: existing.principal.Role, @@ -246,6 +263,85 @@ func IssueOrUpdateToken(path string, principal Principal) (string, error) { return token, nil } +// RotateAgentScope replaces an existing agent token and records the human +// approval and exact scope change in the same durable registry write. +func RotateAgentScope(path string, actor Principal, credentialID string, newScope Scope, confirmation string) (string, CredentialMutationReceipt, error) { + if actor.Role != RoleAdmin || !actor.HumanOperator || actor.LocalAuth { + return "", CredentialMutationReceipt{}, errors.New("authenticated human-operator credential required") + } + if confirmation != "APPROVE" { + return "", CredentialMutationReceipt{}, errors.New("explicit APPROVE confirmation required") + } + if strings.ContainsAny(newScope.WorkspaceID+newScope.Repository+newScope.AgentID, "*?[") { + return "", CredentialMutationReceipt{}, errors.New("credential scope must identify one exact workspace, repository, and agent") + } + lock, err := lockRegistry(path) + if err != nil { + return "", CredentialMutationReceipt{}, err + } + defer unlockRegistry(lock) + registry, err := LoadRegistry(path) + if err != nil { + return "", CredentialMutationReceipt{}, err + } + var subject Principal + for _, existing := range registry.credentials { + if existing.principal.ID == credentialID { + subject = existing.principal + break + } + } + if subject.ID == "" || subject.Role != RoleAgent { + return "", CredentialMutationReceipt{}, errors.New("existing agent credential required") + } + newScope.HookProvider = subject.Scope.HookProvider + newScope.MCPProxy = subject.Scope.MCPProxy + updated := subject + updated.Scope = newScope + if err := validatePrincipal(updated); err != nil { + return "", CredentialMutationReceipt{}, err + } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return "", CredentialMutationReceipt{}, fmt.Errorf("generate token: %w", err) + } + token := "room_" + updated.ID + "_" + base64.RawURLEncoding.EncodeToString(secret) + clear(secret) + digest := sha256.Sum256([]byte(token)) + receiptBytes := make([]byte, 16) + if _, err := rand.Read(receiptBytes); err != nil { + return "", CredentialMutationReceipt{}, fmt.Errorf("generate receipt id: %w", err) + } + receipt := CredentialMutationReceipt{ID: hex.EncodeToString(receiptBytes), ActorID: actor.ID, CredentialID: updated.ID, Action: "rotate_agent_scope", OccurredAt: time.Now().UTC(), OldScope: subject.Scope, NewScope: updated.Scope} + stored := registryFile{Version: registryVersion, Credentials: make([]credentialFile, 0, len(registry.credentials)), CredentialMutations: append(append([]CredentialMutationReceipt(nil), registry.mutations...), receipt)} + for _, existing := range registry.credentials { + principal := existing.principal + tokenDigest := existing.digest + if principal.ID == updated.ID { + principal = updated + tokenDigest = digest + } + stored.Credentials = append(stored.Credentials, credentialFile{ID: principal.ID, Role: principal.Role, TokenSHA256: hex.EncodeToString(tokenDigest[:]), Scope: principal.Scope, HumanOperator: principal.HumanOperator}) + } + if err := writeRegistryAtomic(path, stored); err != nil { + return "", CredentialMutationReceipt{}, err + } + return token, receipt, nil +} + +// CredentialMutationReceipts returns a copy of the registry's durable receipts. +func (r *Registry) CredentialMutationReceipts() []CredentialMutationReceipt { + if r == nil { + return nil + } + return append([]CredentialMutationReceipt(nil), r.mutations...) +} + +// RotateAgentScope applies the audited scope rotation to a reloadable registry. +func (a *FileAuthenticator) RotateAgentScope(actor Principal, credentialID string, scope Scope, confirmation string) (string, CredentialMutationReceipt, error) { + return RotateAgentScope(a.path, actor, credentialID, scope, confirmation) +} + func validatePrincipal(principal Principal) error { if !credentialIDPattern.MatchString(principal.ID) { return errors.New("credential id must use letters, digits, dots, or hyphens") diff --git a/internal/auth/reload_test.go b/internal/auth/reload_test.go index 6d58b9f..afc7e90 100644 --- a/internal/auth/reload_test.go +++ b/internal/auth/reload_test.go @@ -24,8 +24,11 @@ func TestFileAuthenticatorReloadsRotatedCredential(t *testing.T) { if _, err := authenticator.Authenticate(oldToken); err != nil { t.Fatalf("initial token: %v", err) } - - newToken, err := IssueOrUpdateToken(path, principal) + human := Principal{ID: "human", Role: RoleAdmin, HumanOperator: true} + if _, err := IssueOrUpdateToken(path, human); err != nil { + t.Fatal(err) + } + newToken, _, err := RotateAgentScope(path, human, principal.ID, principal.Scope, "APPROVE") if err != nil { t.Fatal(err) } diff --git a/internal/config/config.go b/internal/config/config.go index 3d40531..04f8a79 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -149,6 +149,10 @@ func (c Config) ValidateMCPServer() error { if c.WriteTimeout <= c.ClientTimeout+5*time.Second { return errors.New("ROOM_WRITE_TIMEOUT must exceed ROOM_CLIENT_TIMEOUT by more than 5s for room-mcp") } + return c.ValidateMCPControlPlane() +} + +func (c Config) ValidateMCPControlPlane() error { parsed, err := url.Parse(c.ControlPlaneURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return errors.New("ROOM_CONTROL_PLANE_URL must be an absolute URL") @@ -208,6 +212,13 @@ func LoadToken(path string) (string, error) { if strings.TrimSpace(path) == "" { return "", errors.New("ROOM_TOKEN_FILE or ROOM_TOKEN is required") } + return LoadTokenFile(path) +} + +func LoadTokenFile(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", errors.New("ROOM_TOKEN_FILE is required") + } file, err := os.Open(path) if err != nil { return "", err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8fbd564..fbf6a8c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -135,6 +135,17 @@ func TestValidateMCPServerRequiresSecureFixedControlPlaneURL(t *testing.T) { } } +func TestValidateMCPControlPlaneIgnoresHTTPWriteDeadline(t *testing.T) { + t.Setenv("ROOM_CLIENT_TIMEOUT", "10m") + cfg := Load() + if err := cfg.ValidateMCPControlPlane(); err != nil { + t.Fatalf("stdio-appropriate MCP validation rejected long client timeout: %v", err) + } + if err := cfg.ValidateMCPServer(); err == nil { + t.Fatal("expected HTTP MCP validation to preserve write deadline ordering") + } +} + func TestLoadDefaultsControlPlaneURLToServerURL(t *testing.T) { t.Setenv("ROOM_SERVER_URL", "http://127.0.0.1:9876") t.Setenv("ROOM_CONTROL_PLANE_URL", "") @@ -166,3 +177,19 @@ func TestLoadTokenRequiresPrivateFile(t *testing.T) { t.Fatalf("token = %q, err = %v", token, err) } } + +func TestLoadTokenFileIgnoresRoomToken(t *testing.T) { + t.Setenv("ROOM_TOKEN", "inherited-token") + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte("file-token\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + + token, err := LoadTokenFile(path) + if err != nil { + t.Fatalf("load token file: %v", err) + } + if token != "file-token" { + t.Fatalf("token = %q, want file-token", token) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 053aa4d..2342bdb 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -70,6 +70,16 @@ func NewAuthenticatedHandlerWithControlPlaneURLAndTimeout(serverURL, controlPlan return mcpauth.RequireBearerToken(verifier, &mcpauth.RequireBearerTokenOptions{Scopes: []string{agentScope}})(newStreamableHandler(serverURL, controlPlaneURL, timeout)) } +// NewServerWithTokenAndTimeout creates the native MCP server used by trusted +// local transports such as stdio. The token is applied only to Room API calls. +func NewServerWithTokenAndTimeout(serverURL, controlPlaneURL, token string, timeout time.Duration) *mcpsdk.Server { + h := &Handler{ + client: agentclient.NewAuthenticatedWithTimeout(strings.TrimRight(serverURL, "/"), agentclient.DefaultCachePath(), token, timeout), + controlPlaneURL: strings.TrimRight(controlPlaneURL, "/"), + } + return h.server() +} + func newStreamableHandler(serverURL, controlPlaneURL string, timeout time.Duration) http.Handler { serverURL = strings.TrimRight(serverURL, "/") controlPlaneURL = strings.TrimRight(controlPlaneURL, "/") diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index c861ec0..04cfbc9 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -323,6 +323,54 @@ func TestAnalyzePlanElicitsTypedResolutionAndAuditsAcceptance(t *testing.T) { } } +func TestNativeServerFactoryForwardsTypedElicitation(t *testing.T) { + t.Setenv("ROOM_CACHE_FILE", filepath.Join(t.TempDir(), "ruleset.json")) + ruleStore, err := store.Open(filepath.Join(t.TempDir(), "room.db")) + if err != nil { + t.Fatal(err) + } + defer ruleStore.Close() + identity := &roomv1.AnalyzerIdentity{Id: "test", Version: "1", ConfigSha256: make([]byte, 32)} + roomServer := httptest.NewServer(server.New(app.New(ruleStore, app.WithAnalyzer(&signalAnalyzer{identity: identity})), server.WithLocalAuth())) + defer roomServer.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + serverTransport, clientTransport := mcpsdk.NewInMemoryTransports() + nativeServer := NewServerWithTokenAndTimeout(roomServer.URL, roomServer.URL, "local-test-token", 5*time.Second) + serverExit := make(chan error, 1) + go func() { serverExit <- nativeServer.Run(ctx, serverTransport) }() + + var elicited *mcpsdk.ElicitParams + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "codex-native-test", Version: "test"}, &mcpsdk.ClientOptions{ + ElicitationHandler: func(_ context.Context, request *mcpsdk.ElicitRequest) (*mcpsdk.ElicitResult, error) { + elicited = request.Params + return &mcpsdk.ElicitResult{Action: "accept", Content: map[string]any{"resolution": "revise"}}, nil + }, + Capabilities: &mcpsdk.ClientCapabilities{Elicitation: &mcpsdk.ElicitationCapabilities{Form: &mcpsdk.FormElicitationCapabilities{}}}, + }) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + tools, err := session.ListTools(ctx, nil) + if err != nil || len(tools.Tools) != 4 { + t.Fatalf("native tools = %d, err = %v", len(tools.Tools), err) + } + result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: "room_analyze_plan", Arguments: map[string]any{"plan": "Add a customer endpoint that queries projects from the database."}}) + if err != nil { + t.Fatal(err) + } + if elicited == nil || elicited.Mode != "form" || elicited.RequestedSchema == nil || result.IsError { + t.Fatalf("elicitation = %#v, result = %#v", elicited, result) + } + cancel() + if err := <-serverExit; !errors.Is(err, context.Canceled) { + t.Fatalf("native server exit = %v", err) + } +} + func TestAnalyzePlanReturnsAuditedUnsupportedFallbackWithoutClientCapability(t *testing.T) { t.Setenv("ROOM_CACHE_FILE", filepath.Join(t.TempDir(), "ruleset.json")) ruleStore, err := store.Open(filepath.Join(t.TempDir(), "room.db")) diff --git a/internal/server/router.go b/internal/server/router.go index 6af907f..b5b5e8f 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -22,16 +22,26 @@ import ( var webFS embed.FS type options struct { - authenticator auth.Authenticator - localAuth bool - maxBodyBytes int64 + authenticator auth.Authenticator + credentialRotator interface { + RotateAgentScope(auth.Principal, string, auth.Scope, string) (string, auth.CredentialMutationReceipt, error) + } + localAuth bool + maxBodyBytes int64 } type Option func(*options) func WithRegistry(registry *auth.Registry) Option { return WithAuthenticator(registry) } func WithAuthenticator(authenticator auth.Authenticator) Option { - return func(o *options) { o.authenticator = authenticator } + return func(o *options) { + o.authenticator = authenticator + if rotator, ok := authenticator.(interface { + RotateAgentScope(auth.Principal, string, auth.Scope, string) (string, auth.CredentialMutationReceipt, error) + }); ok { + o.credentialRotator = rotator + } + } } func WithLocalAuth() Option { return func(o *options) { o.localAuth = true } } func WithMaxBodyBytes(value int64) Option { return func(o *options) { o.maxBodyBytes = value } } @@ -164,6 +174,38 @@ func New(service *app.Service, optionValues ...Option) http.Handler { resp, err := service.ListAuditEvents(r.Context(), connect.NewRequest(&roomv1.ListAuditEventsRequest{Limit: 100})) writeProtoJSON(w, message(resp), err) }))) + mux.Handle("/api/credentials/rotate-scope", protectedHandler(settings, auth.RoleAdmin, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if settings.credentialRotator == nil { + writeError(w, connect.NewError(connect.CodeFailedPrecondition, errors.New("credential mutation workflow unavailable"))) + return + } + principal, ok := auth.PrincipalFromContext(r.Context()) + if !ok || !principal.HumanOperator || principal.LocalAuth { + writeError(w, connect.NewError(connect.CodePermissionDenied, errors.New("authenticated human-operator credential required"))) + return + } + var body struct { + CredentialID string `json:"credential_id"` + WorkspaceID string `json:"workspace_id"` + Repository string `json:"repository"` + AgentID string `json:"agent_id"` + Confirmation string `json:"confirmation"` + } + if err := readJSON(w, r, &body, settings.maxBodyBytes); err != nil { + writeError(w, err) + return + } + token, receipt, err := settings.credentialRotator.RotateAgentScope(principal, body.CredentialID, auth.Scope{WorkspaceID: body.WorkspaceID, Repository: body.Repository, AgentID: body.AgentID}, body.Confirmation) + if err != nil { + writeError(w, connect.NewError(connect.CodeInvalidArgument, err)) + return + } + writeJSONResponse(w, map[string]any{"token": token, "receipt": receipt}) + }))) mux.Handle("/api/review-findings", protectedRolesHandler(settings, []auth.Role{auth.RoleAdmin, auth.RoleReviewer}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -349,6 +391,13 @@ func writeProtoJSON(w http.ResponseWriter, value proto.Message, err error) { _, _ = w.Write(data) } +func writeJSONResponse(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + http.Error(w, "encode response", http.StatusInternalServerError) + } +} + func writeError(w http.ResponseWriter, err error) { status := http.StatusInternalServerError var connectErr *connect.Error diff --git a/internal/server/router_test.go b/internal/server/router_test.go index 69c8d32..5e46f93 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -113,6 +114,93 @@ func TestDashboardSupportsNonMutatingPolicyControlDeepLinks(t *testing.T) { } } +func TestCredentialScopeRotationAPIRequiresHumanOperatorAndReturnsAuditedTokenOnce(t *testing.T) { + dir := t.TempDir() + credentials := filepath.Join(dir, "credentials.json") + humanToken, err := auth.IssueOrUpdateToken(credentials, auth.Principal{ID: "human", Role: auth.RoleAdmin, HumanOperator: true}) + if err != nil { + t.Fatal(err) + } + automationToken, err := auth.IssueOrUpdateToken(credentials, auth.Principal{ID: "automation", Role: auth.RoleAdmin}) + if err != nil { + t.Fatal(err) + } + oldToken, err := auth.IssueOrUpdateToken(credentials, auth.Principal{ID: "codex-local", Role: auth.RoleAgent, Scope: auth.Scope{WorkspaceID: "local", Repository: "evalops/room", AgentID: "codex"}}) + if err != nil { + t.Fatal(err) + } + authenticator, err := auth.NewFileAuthenticator(credentials) + if err != nil { + t.Fatal(err) + } + ruleStore, err := store.Open(filepath.Join(dir, "room.db")) + if err != nil { + t.Fatal(err) + } + defer ruleStore.Close() + httpServer := httptest.NewServer(New(app.New(ruleStore), WithAuthenticator(authenticator))) + defer httpServer.Close() + body := `{"credential_id":"codex-local","workspace_id":"local","repository":"evalops/platform","agent_id":"codex","confirmation":"APPROVE"}` + request := func(token string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/credentials/rotate-scope", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp + } + if resp := request(automationToken); resp.StatusCode != http.StatusForbidden { + resp.Body.Close() + t.Fatalf("non-human status = %d", resp.StatusCode) + } else { + resp.Body.Close() + } + resp := request(humanToken) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("rotation status = %d", resp.StatusCode) + } + var result struct { + Token string `json:"token"` + Receipt auth.CredentialMutationReceipt `json:"receipt"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.Token == "" || result.Receipt.ActorID != "human" || result.Receipt.CredentialID != "codex-local" { + t.Fatalf("result = %#v", result) + } + reloaded, err := auth.LoadRegistry(credentials) + if err != nil { + t.Fatal(err) + } + if _, err := reloaded.Authenticate(oldToken); err != auth.ErrUnauthenticated { + t.Fatalf("old token remains valid: %v", err) + } + if principal, err := reloaded.Authenticate(result.Token); err != nil || principal.Scope.Repository != "evalops/platform" { + t.Fatalf("replacement principal = %#v, err = %v", principal, err) + } +} + +func TestDashboardExposesConfirmedCredentialScopeRotation(t *testing.T) { + body, err := webFS.ReadFile("static/index.html") + if err != nil { + t.Fatal(err) + } + html := string(body) + for _, contract := range []string{"Rotate exact agent scope", "scope-confirmation", "Rotate scope and token", "/api/credentials/rotate-scope", "One-time replacement token", "Durable receipt"} { + if !strings.Contains(html, contract) { + t.Fatalf("dashboard missing credential rotation contract %q", contract) + } + } +} + func TestReviewIntelligenceLifecycleAPI(t *testing.T) { ruleStore, err := store.Open(filepath.Join(t.TempDir(), "room.db")) if err != nil { diff --git a/internal/server/static/index.html b/internal/server/static/index.html index 7bd5152..5b95517 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -138,6 +138,16 @@
+