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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
37 changes: 37 additions & 0 deletions cmd/room-mcp-stdio/main.go
Original file line number Diff line number Diff line change
@@ -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
}
91 changes: 79 additions & 12 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
106 changes: 101 additions & 5 deletions internal/auth/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"sort"
"strings"
"sync/atomic"
"time"

"golang.org/x/sys/unix"
)
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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")
Expand Down
7 changes: 5 additions & 2 deletions internal/auth/reload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading