From fe1988964bee12363269cd0b5e9fd6bcf27c953f Mon Sep 17 00:00:00 2001 From: jarvis Date: Sat, 5 Sep 2026 19:12:43 -0400 Subject: [PATCH] fix(cli): acquire existing permissions in fresh Agent sessions --- README.md | 8 ++++ internal/cli/cli.go | 19 ++++++-- internal/cli/existing_authority.go | 58 ++++++++++++++++++++++++ internal/cli/existing_authority_test.go | 59 +++++++++++++++++++++++++ specs/cli.feature | 8 ++++ 5 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 internal/cli/existing_authority.go create mode 100644 internal/cli/existing_authority_test.go diff --git a/README.md b/README.md index 89e7b17..6085a0e 100644 --- a/README.md +++ b/README.md @@ -244,3 +244,11 @@ go vet ./... ``` Licensed under Apache-2.0. + +### Permissions provisioned before execution + +When a controller has already granted permissions, a new Session automatically +acquires the selected Context's existing authority before a protected Toolbox +operation or native command. This does not expand the Agent's permissions or +open interactive approval. Missing permissions still require `realmroot agent +request`. The Session keeps its own credential binding and DPoP key. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8e5a3cf..d128eb1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -173,6 +173,9 @@ func (a *App) execCommand() *cobra.Command { if err != nil { return err } + if err := acquireExistingAuthority(command.Context(), service, accessService, server, details, selected); err != nil { + return err + } runner := execution.NewRunner(service, httpClient, command.InOrStdin(), a.stdout, a.stderr, logger) err = runner.Run(command.Context(), server, integrations, args, execution.RunOptions{ AuthorizationDetails: selected, @@ -350,7 +353,7 @@ func (a *App) toolboxCommand() *cobra.Command { } ctx, cancel := context.WithTimeout(command.Context(), 15*time.Minute) defer cancel() - agentService, catalogClient, _, err := a.services() + agentService, catalogClient, httpClient, err := a.services() if err != nil { return err } @@ -375,7 +378,7 @@ func (a *App) toolboxCommand() *cobra.Command { if a.search != "" || a.all { return errors.New("--search and --all apply only to a Resource Server overview") } - return a.runRestish(ctx, agentService, catalogClient, args) + return a.runRestish(ctx, agentService, catalogClient, httpClient, args) }, } command.Flags().String("output", "auto", "response format: auto, json, yaml, table, or raw") @@ -904,7 +907,7 @@ func scopeList(scopes []string, expanded bool) string { return fmt.Sprintf("%d available (add --all to list them)", len(scopes)) } -func (a *App) runRestish(ctx context.Context, service *agent.Service, client *catalog.Client, args []string) error { +func (a *App) runRestish(ctx context.Context, service *agent.Service, client *catalog.Client, httpClient *http.Client, args []string) error { config, servers, err := client.RestishConfig(ctx) if err != nil { return err @@ -913,6 +916,10 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca if err != nil { return err } + accessService, err := access.New(service, httpClient) + if err != nil { + return err + } var genericOperation *restish.OperationInspection if server, ok := selectedResourceServer(servers, args); ok { profile := "default" @@ -931,6 +938,9 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca if err != nil { return err } + if err := acquireExistingAuthority(ctx, service, accessService, server, details, selected); err != nil { + return err + } } binding, bindingErr := resolveOperationCredentialBinding(service, server, inspection, args[1:], selected) if bindingErr != nil { @@ -963,6 +973,9 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca if contextErr != nil { return contextErr } + if err := acquireExistingAuthority(ctx, service, accessService, server, details, selected); err != nil { + return err + } binding, bindingErr := resolveCredentialBindingForOperation(service, server, operation, selected) if bindingErr != nil { return bindingErr diff --git a/internal/cli/existing_authority.go b/internal/cli/existing_authority.go new file mode 100644 index 0000000..a969fb4 --- /dev/null +++ b/internal/cli/existing_authority.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "slices" + + "github.com/realmroot/cli/internal/access" + "github.com/realmroot/cli/internal/agent" + "github.com/realmroot/cli/internal/catalog" +) + +type existingAuthorityStore interface { + BindingForAuthorizationContextAllAuthority(string, []map[string]any) (agent.CredentialBinding, error) +} +type existingAuthorityRequester interface { + Request(context.Context, catalog.ResourceServer, []string, []map[string]any, string, access.RequestOptions) (access.Receipt, error) +} + +func acquireExistingAuthority(ctx context.Context, store existingAuthorityStore, requester existingAuthorityRequester, server catalog.ResourceServer, contexts []catalog.AuthorizationDetail, selected []map[string]any) error { + var scopes []string + for _, detail := range contexts { + if sameDetails(detail.AuthorizationDetail, selected) { + scopes = slices.Clone(detail.AuthorizedScopes) + slices.Sort(scopes) + scopes = slices.Compact(scopes) + break + } + } + if len(scopes) == 0 { + return nil + } + binding, err := store.BindingForAuthorizationContextAllAuthority(server.ResourceURL, selected) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err == nil && allScopesPresent(scopes, binding.Scopes) { + return nil + } + receipt, err := requester.Request(ctx, server, scopes, selected, "Acquire existing Agent permissions for this Session", access.RequestOptions{Handoff: true}) + if err != nil { + return err + } + if receipt.Status != "ready" { + return fmt.Errorf("previously granted %s permissions are no longer available; request access explicitly", server.CommandName) + } + return nil +} +func allScopesPresent(required, available []string) bool { + for _, scope := range required { + if !slices.Contains(available, scope) { + return false + } + } + return true +} diff --git a/internal/cli/existing_authority_test.go b/internal/cli/existing_authority_test.go new file mode 100644 index 0000000..b15e3c4 --- /dev/null +++ b/internal/cli/existing_authority_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "context" + "os" + "reflect" + "testing" + + "github.com/realmroot/cli/internal/access" + "github.com/realmroot/cli/internal/agent" + "github.com/realmroot/cli/internal/catalog" +) + +type authorityFixture struct { + binding agent.CredentialBinding + err error + requested []string + handoff bool +} + +func (f *authorityFixture) BindingForAuthorizationContextAllAuthority(string, []map[string]any) (agent.CredentialBinding, error) { + return f.binding, f.err +} +func (f *authorityFixture) Request(_ context.Context, _ catalog.ResourceServer, scopes []string, _ []map[string]any, _ string, options access.RequestOptions) (access.Receipt, error) { + f.requested = scopes + f.handoff = options.Handoff + return access.Receipt{Status: "ready"}, nil +} +func TestNewSessionAcquiresOnlyExistingContextPermissions(t *testing.T) { + selected := []map[string]any{{"type": "workspace", "identifier": "one"}} + details := []catalog.AuthorizationDetail{{AuthorizationDetail: selected[0], AuthorizedScopes: []string{"contents:read"}, RequestableScopes: []string{"contents:write"}}} + fixture := &authorityFixture{err: os.ErrNotExist} + if err := acquireExistingAuthority(context.Background(), fixture, fixture, catalog.ResourceServer{ResourceURL: "https://example.test"}, details, selected); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(fixture.requested, []string{"contents:read"}) || !fixture.handoff { + t.Fatalf("unexpected request: %#v", fixture) + } +} +func TestExistingLocalAuthorityDoesNotRequestAgain(t *testing.T) { + selected := []map[string]any{{"type": "workspace", "identifier": "one"}} + details := []catalog.AuthorizationDetail{{AuthorizationDetail: selected[0], AuthorizedScopes: []string{"contents:read"}}} + fixture := &authorityFixture{binding: agent.CredentialBinding{Scopes: []string{"contents:read"}}} + if err := acquireExistingAuthority(context.Background(), fixture, fixture, catalog.ResourceServer{}, details, selected); err != nil { + t.Fatal(err) + } + if fixture.requested != nil { + t.Fatal("requested existing local authority") + } +} +func TestNoGrantDoesNotStartAnApproval(t *testing.T) { + fixture := &authorityFixture{err: os.ErrNotExist} + if err := acquireExistingAuthority(context.Background(), fixture, fixture, catalog.ResourceServer{}, nil, nil); err != nil { + t.Fatal(err) + } + if fixture.requested != nil { + t.Fatal("requested ungranted authority") + } +} diff --git a/specs/cli.feature b/specs/cli.feature index 0393fea..160c150 100644 --- a/specs/cli.feature +++ b/specs/cli.feature @@ -178,3 +178,11 @@ Feature: Realmroot Toolbox command line Then CLI submits the request without a permission precheck And an unsuccessful server response exits with code 1 And JSON output preserves the server error code, message, request ID, and details + + Scenario: A new Session acquires previously granted permissions automatically + Given its Agent already has permissions for the selected Resource Context + And the Session has no local credential binding + When it invokes a protected Toolbox operation or native command + Then Toolbox acquires only the existing permissions without interactive approval + And invokes the requested operation + And missing permissions still require an explicit access request